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": { "personal": {
"name": "Juan Andrés Moreno Rubio", "name": "Juan Andrés Moreno Rubio",
"title": "Lead Technical Consultant, FullStack Developer", "title": "Lead Technical Consultant, FullStack Developer",
"titleBadges": [
"Technical Consultant",
"Full-Stack Engineer",
"Authentication Specialist",
"Solution Architect"
],
"location": "Arrecife, Las Palmas de Gran Canaria, Spain", "location": "Arrecife, Las Palmas de Gran Canaria, Spain",
"email": "txeo.msx@gmail.com", "email": "txeo.msx@gmail.com",
"phone": "+34 676875420", "phone": "+34 676875420",
@@ -12,9 +18,20 @@
"github": "https://github.com/juanatsap", "github": "https://github.com/juanatsap",
"domestika": "https://www.domestika.org/es/txeo/portfolio", "domestika": "https://www.domestika.org/es/txeo/portfolio",
"website": "https://juan.andres.morenorub.io", "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.", "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": [ "experience": [
{ {
"position": "Senior SAP Technical Consultant", "position": "Senior SAP Technical Consultant",
+18 -1
View File
@@ -2,6 +2,12 @@
"personal": { "personal": {
"name": "Juan Andrés Moreno Rubio", "name": "Juan Andrés Moreno Rubio",
"title": "Consultor Técnico Senior, Desarrollador FullStack", "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", "location": "Arrecife, Las Palmas de Gran Canaria, España",
"email": "txeo.msx@gmail.com", "email": "txeo.msx@gmail.com",
"phone": "+34 676875420", "phone": "+34 676875420",
@@ -12,9 +18,20 @@
"github": "https://github.com/juanatsap", "github": "https://github.com/juanatsap",
"domestika": "https://www.domestika.org/es/txeo/portfolio", "domestika": "https://www.domestika.org/es/txeo/portfolio",
"website": "https://juan.andres.morenorub.io", "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.", "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": [ "experience": [
{ {
"position": "Consultor Técnico Senior SAP", "position": "Consultor Técnico Senior SAP",
+123 -10
View File
@@ -1,19 +1,78 @@
{ {
"infoModal": { "navigation": {
"title": "About this CV", "cvSections": "CV Sections",
"description": "This interactive CV was built by myself with <strong>Go + HTMX</strong>, showcasing modern hypermedia architecture without heavy JavaScript frameworks.", "training": "Training",
"techStack": { "skills": "Skills",
"goHono": "Go + Hono", "experience": "Experience",
"htmx": "HTMX", "awards": "Awards",
"html5": "Semantic HTML5", "projects": "Personal / Freelance Projects",
"css3": "Pure CSS3" "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", "defaultCv": {
"viewSourceSubtext": "Want to know how it's built?" "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": { "shortcutsModal": {
"title": "Keyboard Shortcuts", "title": "Keyboard Shortcuts",
"subtitle": "Learn the Shortcuts",
"description": "Use these keyboard shortcuts to navigate and control the CV more efficiently.", "description": "Use these keyboard shortcuts to navigate and control the CV more efficiently.",
"close": "Close",
"sections": { "sections": {
"zoom": { "zoom": {
"title": "Zoom Control", "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": { "navigation": {
"title": "Acerca de este CV", "cvSections": "Secciones 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.", "training": "Formación",
"techStack": { "skills": "Competencias",
"goHono": "Go + Hono", "experience": "Experiencia",
"htmx": "HTMX", "awards": "Premios y Reconocimientos",
"html5": "HTML5 Semántico", "projects": "Proyectos Personales / Freelance",
"css3": "CSS3 Puro" "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", "defaultCv": {
"viewSourceSubtext": "¿Quieres saber cómo está hecho?" "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": { "shortcutsModal": {
"title": "Atajos de Teclado", "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.", "description": "Usa estos atajos de teclado para navegar y controlar el CV de forma más eficiente.",
"close": "Cerrar",
"sections": { "sections": {
"zoom": { "zoom": {
"title": "Control de 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 package cv
import "html/template"
// CV represents the complete curriculum vitae structure // CV represents the complete curriculum vitae structure
type CV struct { type CV struct {
Personal Personal `json:"personal"` Personal Personal `json:"personal"`
SEO SEO `json:"seo"`
Summary string `json:"summary"` Summary string `json:"summary"`
SkillsSummary template.HTML `json:"skillsSummary"`
Experience []Experience `json:"experience"` Experience []Experience `json:"experience"`
Education []Education `json:"education"` Education []Education `json:"education"`
Skills Skills `json:"skills"` Skills Skills `json:"skills"`
@@ -18,19 +22,31 @@ type CV struct {
} }
type Personal struct { type Personal struct {
Name string `json:"name"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
Location string `json:"location"` TitleBadges []string `json:"titleBadges"`
Email string `json:"email"` Location string `json:"location"`
Phone string `json:"phone"` Email string `json:"email"`
DateOfBirth string `json:"dateOfBirth"` Phone string `json:"phone"`
PlaceOfBirth string `json:"placeOfBirth"` DateOfBirth string `json:"dateOfBirth"`
Citizenship string `json:"citizenship"` PlaceOfBirth string `json:"placeOfBirth"`
LinkedIn string `json:"linkedin"` Citizenship string `json:"citizenship"`
GitHub string `json:"github"` LinkedIn string `json:"linkedin"`
Domestika string `json:"domestika"` GitHub string `json:"github"`
Website string `json:"website"` Domestika string `json:"domestika"`
Photo string `json:"photo"` 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 { type Experience struct {
+131 -12
View File
@@ -4,28 +4,95 @@ import "html/template"
// UI represents user interface translations and configuration // UI represents user interface translations and configuration
type UI struct { 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"` ShortcutsModal ShortcutsModal `json:"shortcutsModal"`
InfoModal InfoModal `json:"infoModal"`
Widgets Widgets `json:"widgets"`
} }
type InfoModal struct { // Navigation labels for hamburger menu
Title string `json:"title"` type Navigation struct {
Description template.HTML `json:"description"` CvSections string `json:"cvSections"`
TechStack TechStack `json:"techStack"` Training string `json:"training"`
ViewSource string `json:"viewSource"` Skills string `json:"skills"`
ViewSourceSubtext string `json:"viewSourceSubtext"` 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 { // ViewControls labels for toggle buttons
GoHono string `json:"goHono"` type ViewControls struct {
HTMX string `json:"htmx"` Length string `json:"length"`
HTML5 string `json:"html5"` Icons string `json:"icons"`
CSS3 string `json:"css3"` 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 { type ShortcutsModal struct {
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"`
Description string `json:"description"` Description string `json:"description"`
Close string `json:"close"`
Sections ShortcutsSections `json:"sections"` Sections ShortcutsSections `json:"sections"`
} }
@@ -59,3 +126,55 @@ type ShortcutItem struct {
Key string `json:"key"` Key string `json:"key"`
Description string `json:"description"` 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"> <details class="sidebar-accordion">
<summary class="sidebar-accordion-header"> <summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon> <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> <iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary> </summary>
<div class="sidebar-accordion-content"> <div class="sidebar-accordion-content">
@@ -96,7 +96,7 @@
<details class="sidebar-accordion"> <details class="sidebar-accordion">
<summary class="sidebar-accordion-header"> <summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon> <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> <iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary> </summary>
<div class="sidebar-accordion-content"> <div class="sidebar-accordion-content">
+10 -10
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title> <title>{{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}"> <meta name="title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<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="description" content="{{.CV.Personal.Title}} | {{.CV.SEO.MetaDescription}}">
<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}}"> <meta name="keywords" content="{{.CV.Personal.Name}}, {{.CV.SEO.Keywords}}">
<meta name="author" content="{{.CV.Personal.Name}}"> <meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
@@ -21,18 +21,18 @@
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="profile"> <meta property="og:type" content="profile">
<meta property="og:url" content="{{.CV.Personal.Website}}"> <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:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<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:description" content="{{.CV.Personal.Title}} | {{.CV.SEO.OgDescription}}">
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg"> <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:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
<meta property="og:site_name" content="{{.CV.Personal.Name}}"> <meta property="og:site_name" content="{{.CV.Personal.Name}}">
<meta property="profile:first_name" content="Juan Andrés"> <meta property="profile:first_name" content="{{.CV.Personal.FirstName}}">
<meta property="profile:last_name" content="Moreno Rubio"> <meta property="profile:last_name" content="{{.CV.Personal.LastName}}">
<meta property="profile:username" content="txeo"> <meta property="profile:username" content="{{.CV.Personal.Username}}">
<!-- Social Media Card (Generic) --> <!-- Social Media Card (Generic) -->
<meta name="twitter:card" content="summary"> <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:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg"> <meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
+2 -2
View File
@@ -35,7 +35,7 @@
<details class="sidebar-accordion"> <details class="sidebar-accordion">
<summary class="sidebar-accordion-header"> <summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon> <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> <iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary> </summary>
<div class="sidebar-accordion-content"> <div class="sidebar-accordion-content">
@@ -89,7 +89,7 @@
<details class="sidebar-accordion"> <details class="sidebar-accordion">
<summary class="sidebar-accordion-header"> <summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon> <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> <iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary> </summary>
<div class="sidebar-accordion-content"> <div class="sidebar-accordion-content">
+2 -2
View File
@@ -6,10 +6,10 @@
<p style="text-align: center; margin-bottom: 0.5rem;"> <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;"> <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> <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> </a>
</p> </p>
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} | <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> </footer>
{{end}} {{end}}
+1 -7
View File
@@ -1,12 +1,6 @@
{{define "title-badges"}} {{define "title-badges"}}
<!-- Professional Title Badges - Full Width Top Bar --> <!-- Professional Title Badges - Full Width Top Bar -->
<div class="cv-title-badges-header"> <div class="cv-title-badges-header">
<span class="title-badge">{{if eq .Lang "es"}}CONSULTOR TÉCNICO{{else}}TECHNICAL CONSULTANT{{end}}</span> {{range $i, $badge := .CV.Personal.TitleBadges}}{{if $i}}<span class="badge-separator">|</span>{{end}}<span class="title-badge">{{$badge}}</span>{{end}}
<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>
</div> </div>
{{end}} {{end}}
+1 -1
View File
@@ -3,7 +3,7 @@
<dialog id="info-modal" class="info-modal no-print" <dialog id="info-modal" class="info-modal no-print"
_="on click call closeOnBackdrop(me, event)"> _="on click call closeOnBackdrop(me, event)">
<div class="info-modal-content"> <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> <iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button> </button>
+18 -18
View File
@@ -8,10 +8,10 @@
<div class="pdf-loading-content"> <div class="pdf-loading-content">
<div class="pdf-loading-spinner"></div> <div class="pdf-loading-spinner"></div>
<h3 class="pdf-loading-title" id="pdf-loading-title"> <h3 class="pdf-loading-title" id="pdf-loading-title">
{{if eq .Lang "es"}}Preparando PDF...{{else}}Preparing PDF...{{end}} {{.UI.PdfModal.PreparingPdf}}
</h3> </h3>
<p class="pdf-loading-message" id="pdf-loading-message"> <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>
<p class="pdf-loading-estimate" id="pdf-loading-estimate"></p> <p class="pdf-loading-estimate" id="pdf-loading-estimate"></p>
</div> </div>
@@ -20,16 +20,16 @@
<!-- Close Button --> <!-- Close Button -->
<button class="info-modal-close" <button class="info-modal-close"
onclick="document.getElementById('pdf-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> <iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button> </button>
<!-- Header --> <!-- Header -->
<div class="info-modal-header"> <div class="info-modal-header">
<iconify-icon icon="catppuccin:pdf" width="40" height="40" style="margin-bottom: 0.5rem;"></iconify-icon> <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"> <p class="pdf-modal-subtitle">
{{if eq .Lang "es"}}Elige tu formato preferido{{else}}Choose your preferred format{{end}} {{.UI.PdfModal.Subtitle}}
</p> </p>
</div> </div>
@@ -41,7 +41,7 @@
data-cv-format="short" data-cv-format="short"
role="radio" role="radio"
aria-checked="false" 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" tabindex="0"
_="on click call selectPdfCard(me) _="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)"> on keydown call handlePdfCardKey(me, event)">
@@ -57,13 +57,13 @@
<!-- Page count badge --> <!-- Page count badge -->
<div class="thumbnail-badge"> <div class="thumbnail-badge">
{{if eq .Lang "es"}}4 Páginas{{else}}4 Pages{{end}} {{.UI.PdfModal.ShortCv.Pages}}
</div> </div>
</div> </div>
<div class="pdf-option-info"> <div class="pdf-option-info">
<h3>{{if eq .Lang "es"}}CV Corto (4 páginas){{else}}Short CV (4 pages){{end}}</h3> <h3>{{.UI.PdfModal.ShortCv.Title}}</h3>
<p>{{if eq .Lang "es"}}Información esencial{{else}}Essential info{{end}}</p> <p>{{.UI.PdfModal.ShortCv.Description}}</p>
</div> </div>
<div class="pdf-option-badge"> <div class="pdf-option-badge">
@@ -76,7 +76,7 @@
data-cv-format="default" data-cv-format="default"
role="radio" role="radio"
aria-checked="true" 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" tabindex="0"
_="on click call selectPdfCard(me) _="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)"> on keydown call handlePdfCardKey(me, event)">
@@ -97,16 +97,16 @@
<!-- Page count badge with star --> <!-- Page count badge with star -->
<div class="thumbnail-badge" style="font-weight: 600;"> <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> </div>
<div class="pdf-option-info"> <div class="pdf-option-info">
<h3> <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> <span style="color: #667eea; font-size: 0.9em;"></span>
</h3> </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>
<div class="pdf-option-badge"> <div class="pdf-option-badge">
@@ -119,7 +119,7 @@
data-cv-format="long" data-cv-format="long"
role="radio" role="radio"
aria-checked="false" 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" tabindex="0"
_="on click call selectPdfCard(me) _="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)"> on keydown call handlePdfCardKey(me, event)">
@@ -137,13 +137,13 @@
<!-- Page count badge --> <!-- Page count badge -->
<div class="thumbnail-badge"> <div class="thumbnail-badge">
{{if eq .Lang "es"}}9 Páginas{{else}}9 Pages{{end}} {{.UI.PdfModal.ExtendedCv.Pages}}
</div> </div>
</div> </div>
<div class="pdf-option-info"> <div class="pdf-option-info">
<h3>{{if eq .Lang "es"}}CV Extendido (9 páginas){{else}}Extended CV (9 pages){{end}}</h3> <h3>{{.UI.PdfModal.ExtendedCv.Title}}</h3>
<p>{{if eq .Lang "es"}}Todos los detalles{{else}}All details{{end}}</p> <p>{{.UI.PdfModal.ExtendedCv.Description}}</p>
</div> </div>
<div class="pdf-option-badge"> <div class="pdf-option-badge">
@@ -158,7 +158,7 @@
id="pdf-download-btn" id="pdf-download-btn"
onclick="downloadPDF()"> onclick="downloadPDF()">
<iconify-icon icon="mdi:download" width="20" height="20"></iconify-icon> <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> </button>
</div> </div>
@@ -3,7 +3,7 @@
<dialog id="shortcuts-modal" class="info-modal no-print" <dialog id="shortcuts-modal" class="info-modal no-print"
_="on click call closeOnBackdrop(me, event)"> _="on click call closeOnBackdrop(me, event)">
<div class="info-modal-content"> <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> <iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button> </button>
@@ -13,7 +13,7 @@
<span class="keyboard-icon-wrapper"> <span class="keyboard-icon-wrapper">
<iconify-icon icon="mdi:keyboard-outline" width="32" height="32"></iconify-icon> <iconify-icon icon="mdi:keyboard-outline" width="32" height="32"></iconify-icon>
</span> </span>
{{if eq .Lang "es"}}Aprende los Atajos{{else}}Learn the Shortcuts{{end}} {{.UI.ShortcutsModal.Subtitle}}
</div> </div>
</div> </div>
@@ -5,22 +5,22 @@
id="action-bar-pdf-btn" id="action-bar-pdf-btn"
class="action-btn pdf-btn has-tooltip" class="action-btn pdf-btn has-tooltip"
onclick="document.getElementById('pdf-modal').showModal()" onclick="document.getElementById('pdf-modal').showModal()"
aria-label="{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}" aria-label="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
data-tooltip="{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}" data-tooltip="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
_="on mouseenter call syncPdfHover(true) _="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)"> on mouseleave call syncPdfHover(false)">
<iconify-icon icon="catppuccin:pdf" width="24" height="24"></iconify-icon> <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>
<button <button
class="action-btn print-btn action-bar-print-btn has-tooltip" class="action-btn print-btn action-bar-print-btn has-tooltip"
aria-label="{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}" aria-label="{{.UI.Widgets.ActionButtons.PrintFriendly}}"
data-tooltip="{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}" data-tooltip="{{.UI.Widgets.ActionButtons.PrintFriendly}}"
_="on click call printFriendly() _="on click call printFriendly()
on mouseenter call syncPrintHover(true) on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)"> on mouseleave call syncPrintHover(false)">
<iconify-icon icon="mdi:leaf" width="24" height="24"></iconify-icon> <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> </button>
</div> </div>
{{end}} {{end}}
@@ -6,54 +6,54 @@
<div class="menu-item-submenu"> <div class="menu-item-submenu">
<a href="#" class="menu-item has-submenu"> <a href="#" class="menu-item has-submenu">
<iconify-icon icon="mdi:menu" width="20" height="20"></iconify-icon> <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> <iconify-icon icon="mdi:chevron-right" width="16" height="16" class="submenu-arrow"></iconify-icon>
</a> </a>
<div class="submenu-content"> <div class="submenu-content">
<a href="#education" class="menu-item" <a href="#education" class="menu-item"
_="on click call scrollToSection(event, 'education')"> _="on click call scrollToSection(event, 'education')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon> <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>
<a href="#skills" class="menu-item" <a href="#skills" class="menu-item"
_="on click call scrollToSection(event, 'skills')"> _="on click call scrollToSection(event, 'skills')">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon> <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>
<a href="#experience" class="menu-item" <a href="#experience" class="menu-item"
_="on click call scrollToSection(event, 'experience')"> _="on click call scrollToSection(event, 'experience')">
<iconify-icon icon="mdi:office-building" width="20" height="20"></iconify-icon> <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>
<a href="#awards" class="menu-item" <a href="#awards" class="menu-item"
_="on click call scrollToSection(event, 'awards')"> _="on click call scrollToSection(event, 'awards')">
<iconify-icon icon="mdi:trophy" width="20" height="20"></iconify-icon> <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>
<a href="#projects" class="menu-item" <a href="#projects" class="menu-item"
_="on click call scrollToSection(event, 'projects')"> _="on click call scrollToSection(event, 'projects')">
<iconify-icon icon="mdi:web" width="20" height="20"></iconify-icon> <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>
<a href="#courses" class="menu-item" <a href="#courses" class="menu-item"
_="on click call scrollToSection(event, 'courses')"> _="on click call scrollToSection(event, 'courses')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon> <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>
<a href="#languages" class="menu-item" <a href="#languages" class="menu-item"
_="on click call scrollToSection(event, 'languages')"> _="on click call scrollToSection(event, 'languages')">
<iconify-icon icon="mdi:translate" width="20" height="20"></iconify-icon> <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>
<a href="#references" class="menu-item" <a href="#references" class="menu-item"
_="on click call scrollToSection(event, 'references')"> _="on click call scrollToSection(event, 'references')">
<iconify-icon icon="mdi:link-variant" width="20" height="20"></iconify-icon> <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>
<a href="#other" class="menu-item" <a href="#other" class="menu-item"
_="on click call scrollToSection(event, 'other')"> _="on click call scrollToSection(event, 'other')">
<iconify-icon icon="mdi:information" width="20" height="20"></iconify-icon> <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> </a>
</div> </div>
</div> </div>
@@ -62,21 +62,21 @@
<div class="menu-section-wrapper"> <div class="menu-section-wrapper">
<div class="menu-item menu-item-header"> <div class="menu-item menu-item-header">
<iconify-icon icon="mdi:cog-outline" width="20" height="20"></iconify-icon> <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> </div>
<a href="#" class="menu-item menu-item-action" _="on click call collapseAllSections(event)"> <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> <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>
<a href="#" class="menu-item menu-item-action" _="on click call expandAllSections(event)"> <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> <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>
<a href="#" id="show-zoom-menu-btn" class="menu-item menu-item-action zoom-hidden" <a href="#" id="show-zoom-menu-btn" class="menu-item menu-item-action zoom-hidden"
_="on click call showZoomControl()"> _="on click call showZoomControl()">
<iconify-icon icon="mdi:magnify" width="20" height="20"></iconify-icon> <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> </a>
</div> </div>
@@ -84,14 +84,14 @@
<div class="menu-controls-section"> <div class="menu-controls-section">
<div class="menu-item menu-item-header"> <div class="menu-item menu-item-header">
<iconify-icon icon="mdi:tune-variant" width="20" height="20"></iconify-icon> <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> </div>
<!-- CV Length toggle --> <!-- CV Length toggle -->
<div class="menu-control-item" id="mobile-length-toggle"> <div class="menu-control-item" id="mobile-length-toggle">
<label class="menu-control-label"> <label class="menu-control-label">
<iconify-icon icon="mdi:file-document-outline" width="20" height="20"></iconify-icon> <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> </label>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle"> <label class="icon-toggle">
@@ -118,7 +118,7 @@
<div class="menu-control-item" id="mobile-icon-toggle"> <div class="menu-control-item" id="mobile-icon-toggle">
<label class="menu-control-label"> <label class="menu-control-label">
<iconify-icon icon="mdi:image-multiple-outline" width="20" height="20"></iconify-icon> <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> </label>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle"> <label class="icon-toggle">
@@ -145,7 +145,7 @@
<div class="menu-control-item" id="mobile-theme-toggle"> <div class="menu-control-item" id="mobile-theme-toggle">
<label class="menu-control-label"> <label class="menu-control-label">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="20" height="20"></iconify-icon> <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> </label>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle"> <label class="icon-toggle">
@@ -173,7 +173,7 @@
<div class="menu-actions-section"> <div class="menu-actions-section">
<div class="menu-item menu-item-header"> <div class="menu-item menu-item-header">
<iconify-icon icon="mdi:lightning-bolt" width="20" height="20"></iconify-icon> <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> </div>
<button class="menu-action-btn menu-pdf-btn" <button class="menu-action-btn menu-pdf-btn"
@@ -181,7 +181,7 @@
_="on mouseenter call syncPdfHover(true) _="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)"> on mouseleave call syncPdfHover(false)">
<iconify-icon icon="catppuccin:pdf" width="20" height="20"></iconify-icon> <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>
<button class="menu-action-btn menu-print-btn" <button class="menu-action-btn menu-print-btn"
@@ -189,7 +189,7 @@
on mouseenter call syncPrintHover(true) on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)"> on mouseleave call syncPrintHover(false)">
<iconify-icon icon="mdi:leaf" width="20" height="20"></iconify-icon> <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> </button>
</div> </div>
</div> </div>
@@ -3,7 +3,7 @@
<div class="view-controls-center"> <div class="view-controls-center">
<!-- CV Length toggle --> <!-- CV Length toggle -->
<div class="selector-group" id="desktop-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"> <label class="icon-toggle">
<input type="checkbox" <input type="checkbox"
id="lengthToggle" id="lengthToggle"
@@ -25,7 +25,7 @@
<!-- Icon toggle --> <!-- Icon toggle -->
<div class="selector-group" id="desktop-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"> <label class="icon-toggle">
<input type="checkbox" <input type="checkbox"
id="iconToggle" id="iconToggle"
@@ -47,7 +47,7 @@
<!-- Theme toggle --> <!-- Theme toggle -->
<div class="selector-group" id="desktop-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"> <label class="icon-toggle">
<input type="checkbox" <input type="checkbox"
id="themeToggle" id="themeToggle"
+1 -1
View File
@@ -8,7 +8,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:trophy" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.Awards}} {{range .CV.Awards}}
+1 -1
View File
@@ -8,7 +8,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:school" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.Courses}} {{range .CV.Courses}}
+2 -2
View File
@@ -7,12 +7,12 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:school" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.Education}} {{range .CV.Education}}
<div class="education-item"> <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> </div>
{{end}} {{end}}
</details> </details>
+4 -4
View File
@@ -7,7 +7,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:office-building" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
@@ -22,10 +22,10 @@
</div> </div>
<div class="experience-content"> <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> <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 .Current}}<span class="current-badge">{{$.UI.Sections.CurrentBadge}}</span>{{end}}
{{if .Expired}}<span class="expired-badge">{{if eq $.Lang "es"}}EXPIRADO{{else}}EXPIRED{{end}}</span>{{end}} {{if .Expired}}<span class="expired-badge">{{$.UI.Sections.ExpiredBadge}}</span>{{end}}
<br> <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}} {{if .ShortDescription}}
<p class="experience-desc short-desc">{{.ShortDescription | safeHTML}}</p> <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-content">
<div class="cv-header-left"> <div class="cv-header-left">
<h1 class="cv-name">Moreno Rubio, Juan Andrés</h1> <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) --> <!-- Photo positioned for mobile (centered between name and intro) -->
<div class="cv-photo"> <div class="cv-photo">
+1 -1
View File
@@ -7,7 +7,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:translate" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.Languages}} {{range .CV.Languages}}
+2 -2
View File
@@ -8,11 +8,11 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:information" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
<div class="other-content"> <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> </div>
</details> </details>
</div> </div>
+6 -6
View File
@@ -8,7 +8,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:web" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.Projects}} {{range .CV.Projects}}
@@ -31,9 +31,9 @@
{{end}} {{end}}
</strong> </strong>
{{if .Current}}<span class="live-badge"><iconify-icon icon="mdi:wifi" width="14" height="14"></iconify-icon>LIVE</span>{{end}} {{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> <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}} {{if .ShortDescription}}
<p class="project-desc short-desc">{{.ShortDescription | safeHTML}}</p> <p class="project-desc short-desc">{{.ShortDescription | safeHTML}}</p>
@@ -49,7 +49,7 @@
{{if .Technologies}} {{if .Technologies}}
<div class="project-technologies long-only"> <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}} {{range $index, $tech := .Technologies}}{{if $index}}, {{end}}{{$tech}}{{end}}
</div> </div>
{{end}} {{end}}
@@ -59,8 +59,8 @@
<!-- Link to full portfolio --> <!-- Link to full portfolio -->
<div class="projects-footer"> <div class="projects-footer">
<p>{{if eq .Lang "es"}}Ver todos los proyectos en mi{{else}}See all projects on my{{end}} <p>{{.UI.Portfolio.SeeAllProjects}}
<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> <a href="{{.CV.Personal.Domestika}}" target="_blank" rel="noopener noreferrer"><strong>{{.UI.Portfolio.DomestikaPortfolio}}</strong></a></p>
</div> </div>
</details> </details>
</div> </div>
+1 -1
View File
@@ -8,7 +8,7 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:link-variant" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
{{range .CV.References}} {{range .CV.References}}
@@ -7,16 +7,10 @@
<summary> <summary>
<h3 class="section-title"> <h3 class="section-title">
<iconify-icon icon="mdi:brain" width="24" height="24" class="section-icon"></iconify-icon> <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> </h3>
</summary> </summary>
<p class="summary-text"> <p class="summary-text">{{.CV.SkillsSummary}}</p>
{{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>
</details> </details>
</div> </div>
+2 -2
View File
@@ -2,8 +2,8 @@
<!-- Back to Top Link - Hyperscript smooth scroll without URL pollution --> <!-- Back to Top Link - Hyperscript smooth scroll without URL pollution -->
<button id="back-to-top" <button id="back-to-top"
class="back-to-top no-print has-tooltip tooltip-left" class="back-to-top no-print has-tooltip tooltip-left"
aria-label="{{if eq .Lang "es"}}Volver arriba{{else}}Back to top{{end}}" aria-label="{{.UI.Widgets.BackToTop.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Volver arriba{{else}}Back to top{{end}}" data-tooltip="{{.UI.Widgets.BackToTop.Tooltip}}"
style="display: none;" style="display: none;"
_="on click call scrollToTop(event)"> _="on click call scrollToTop(event)">
<iconify-icon icon="mdi:arrow-up" width="24" height="24"></iconify-icon> <iconify-icon icon="mdi:arrow-up" width="24" height="24"></iconify-icon>
@@ -3,8 +3,8 @@
<button <button
id="download-button" id="download-button"
class="fixed-btn download-btn no-print has-tooltip" class="fixed-btn download-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}" aria-label="{{.UI.Widgets.Download.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}" data-tooltip="{{.UI.Widgets.Download.Tooltip}}"
onclick="openPdfModal()" onclick="openPdfModal()"
_="on mouseenter call syncPdfHover(true) _="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)"> on mouseleave call syncPdfHover(false)">
+2 -2
View File
@@ -1,8 +1,8 @@
{{define "info-button"}} {{define "info-button"}}
<!-- Info Button (Bottom Left) --> <!-- Info Button (Bottom Left) -->
<button id="info-button" class="info-button no-print has-tooltip" <button id="info-button" class="info-button no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Información{{else}}Information{{end}}" aria-label="{{.UI.Widgets.Info.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Información{{else}}Information{{end}}" data-tooltip="{{.UI.Widgets.Info.Tooltip}}"
onclick="document.getElementById('info-modal').showModal()"> onclick="document.getElementById('info-modal').showModal()">
<iconify-icon icon="mdi:information-outline" width="24" height="24"></iconify-icon> <iconify-icon icon="mdi:information-outline" width="24" height="24"></iconify-icon>
</button> </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"> <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> <span class="toast-icon" id="pdf-toast-icon">📥</span>
<div class="toast-content"> <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> <p class="toast-message" id="pdf-toast-message"></p>
</div> </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">
<div class="toast-progress-bar" id="pdf-toast-progress"></div> <div class="toast-progress-bar" id="pdf-toast-progress"></div>
</div> </div>
@@ -3,8 +3,8 @@
<button <button
id="print-friendly-button" id="print-friendly-button"
class="fixed-btn print-friendly-btn no-print has-tooltip" class="fixed-btn print-friendly-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Imprimir CV{{else}}Print Friendly{{end}}" aria-label="{{.UI.Widgets.Print.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Imprimir CV{{else}}Print Friendly{{end}}" data-tooltip="{{.UI.Widgets.Print.Tooltip}}"
onclick="window.print()" onclick="window.print()"
_="on mouseenter call syncPrintHover(true) _="on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)"> on mouseleave call syncPrintHover(false)">
@@ -4,8 +4,8 @@
id="shortcuts-button" id="shortcuts-button"
class="fixed-btn shortcuts-btn no-print has-tooltip" class="fixed-btn shortcuts-btn no-print has-tooltip"
onclick="document.getElementById('shortcuts-modal').showModal()" onclick="document.getElementById('shortcuts-modal').showModal()"
aria-label="{{if eq .Lang "es"}}Atajos de teclado{{else}}Keyboard shortcuts{{end}}" aria-label="{{.UI.Widgets.Shortcuts.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Atajos de teclado (?){{else}}Keyboard shortcuts (?){{end}}"> data-tooltip="{{.UI.Widgets.Shortcuts.Tooltip}}">
<iconify-icon icon="mdi:keyboard-outline" width="28" height="28"></iconify-icon> <iconify-icon icon="mdi:keyboard-outline" width="28" height="28"></iconify-icon>
</button> </button>
{{end}} {{end}}
+6 -6
View File
@@ -1,6 +1,6 @@
{{define "zoom-control"}} {{define "zoom-control"}}
<!-- Zoom Control (Fixed Bottom Center, Draggable) - Hyperscript Enhanced --> <!-- 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 load call initZoomControl(me)
on mousedown(clientX, clientY) if isZoomDragTarget(event.target) call startZoomDrag(me, clientX, clientY) then halt the event end 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 on mousemove(clientX, clientY) from document if moveZoomDrag(me, clientX, clientY) halt the event end
@@ -9,8 +9,8 @@
<button <button
id="zoom-close" id="zoom-close"
class="zoom-close-btn" class="zoom-close-btn"
aria-label="{{if eq .Lang "es"}}Cerrar control de zoom{{else}}Close zoom control{{end}}" aria-label="{{.UI.Widgets.ZoomControl.CloseLabel}}"
title="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}" title="{{.UI.Widgets.ZoomControl.CloseTitle}}"
_="on click call hideZoomControl()"> _="on click call hideZoomControl()">
<iconify-icon icon="mdi:close" width="16" height="16" style="pointer-events: none;"></iconify-icon> <iconify-icon icon="mdi:close" width="16" height="16" style="pointer-events: none;"></iconify-icon>
</button> </button>
@@ -25,7 +25,7 @@
max="300" max="300"
step="1" step="1"
value="100" 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-valuemin="25"
aria-valuemax="300" aria-valuemax="300"
aria-valuenow="100" aria-valuenow="100"
@@ -37,8 +37,8 @@
<button <button
id="zoom-reset" id="zoom-reset"
class="zoom-reset-btn" class="zoom-reset-btn"
aria-label="{{if eq .Lang "es"}}Restablecer zoom al 100%{{else}}Reset zoom to 100%{{end}}" aria-label="{{.UI.Widgets.ZoomControl.ResetLabel}}"
title="{{if eq .Lang "es"}}Restablecer{{else}}Reset{{end}}" title="{{.UI.Widgets.ZoomControl.ResetTitle}}"
aria-live="polite" aria-live="polite"
_="on click call handleZoomReset()"> _="on click call handleZoomReset()">
<span id="zoom-value-current">100</span> <span id="zoom-value-current">100</span>
@@ -3,8 +3,8 @@
<button <button
id="zoom-toggle-button" id="zoom-toggle-button"
class="fixed-btn zoom-toggle-btn no-print has-tooltip" 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}}" aria-label="{{.UI.Widgets.ZoomToggle.AriaLabel}}"
data-tooltip="{{if eq .Lang "es"}}Control de zoom{{else}}Zoom control{{end}}" data-tooltip="{{.UI.Widgets.ZoomToggle.Tooltip}}"
_="on click call toggleZoomControl() _="on click call toggleZoomControl()
on mouseenter call highlightZoomControl(true) on mouseenter call highlightZoomControl(true)
on mouseleave call highlightZoomControl(false)"> 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();