feat: lazy load ninja-keys + HTML Invoker Commands API

- Lazy load ninja-keys only on CMD+K press (0 requests on initial load)
- Use esm.sh bundled module (3 requests vs ~81 previously)
- Add esm.sh to CSP whitelist
- Implement HTML Invoker Commands API for modals:
  - commandfor="modal-id" + command="show-modal" for opening
  - commandfor="modal-id" + command="close" for closing
  - Removes need for onclick handlers on modal buttons
- Refactor index.html into layout partials (head, body-scripts)
- Add comprehensive tests for both features
This commit is contained in:
juanatsap
2025-12-02 08:29:54 +00:00
parent c6411db9c8
commit 2d3d3de8cd
22 changed files with 1489 additions and 386 deletions
+23 -369
View File
@@ -1,342 +1,6 @@
<!DOCTYPE html>
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<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}}">
<!-- Hreflang tags for international SEO -->
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}">
<link rel="alternate" hreflang="es" href="{{.AlternateES}}">
<link rel="alternate" hreflang="x-default" href="https://juan.andres.morenorub.io/?lang=en">
<!-- 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}} - {{.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="{{.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}} - {{.CV.SEO.MetaTitle}}">
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<!-- HTMX Configuration -->
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
<!-- FOUC Prevention: Inline critical CSS + Apply color theme before page render -->
<!-- Critical theme variables inlined to prevent flash of unstyled content -->
<style>
/* Light theme (default) - critical variables only */
:root {
--page-bg: #d6d6d6;
--paper-bg: #ffffff;
--text-primary: #1a1a1a;
--sidebar-bg: #d1d4d2;
}
/* Dark theme - critical variables only */
[data-color-theme="dark"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
/* Auto theme follows system preference */
@media (prefers-color-scheme: dark) {
[data-color-theme="auto"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
}
/* Apply critical styles immediately */
html { background-color: var(--page-bg); }
body { color: var(--text-primary); }
</style>
<script>
(function() {
// First-time visitors ALWAYS get light theme (paper aesthetic)
// Users can switch to dark/auto and their preference is saved
let theme = localStorage.getItem('color-theme-mode') || 'light';
document.documentElement.setAttribute('data-color-theme', theme);
})();
</script>
<!-- Device Detection - Detect real mobile devices vs desktop browser -->
<script src="/static/js/device-detection.js"></script>
<!-- HTMX with SRI (Subresource Integrity) -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- Hyperscript Functions - Must load BEFORE hyperscript library -->
<!-- NOTE: cv-functions.js removed - hyperscript def statements are globally available -->
<!-- ✅ NO def limit with latest hyperscript - organized by category -->
<script type="text/hyperscript" src="/static/hyperscript/utils._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/toggles._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/hover-sync._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/keyboard._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/zoom._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/pdf-modal._hs"></script>
<!-- Color Theme System (JavaScript - hyperscript had parsing issues with colons in strings) -->
<script src="/static/js/color-theme.js"></script>
<!-- NOTE: footer-buttons-interaction.js removed - moved to hyperscript on footer element -->
<!-- NOTE: scroll-at-bottom-handler.js removed - duplicate of handleScroll() in utils._hs -->
<!-- Hyperscript - Declarative event handling for enhanced interactivity -->
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
<!-- Ninja Keys - CMD+K Command Bar -->
<script type="module" src="https://unpkg.com/ninja-keys?module"></script>
<!-- Iconify - Load synchronously for immediate rendering -->
<!-- Using unpkg CDN (more reliable than code.iconify.design) -->
<script src="https://cdn.jsdelivr.net/npm/iconify-icon@2.1.0/dist/iconify-icon.min.js"></script>
<!-- CSS - Conditional loading: bundled in production, modular in development -->
{{if .IsProduction}}
<link rel="stylesheet" href="/static/dist/bundle.min.css">
{{else}}
<link rel="stylesheet" href="/static/css/main.css">
{{end}}
<!-- Print styles - loaded separately, only applied when printing -->
<link rel="stylesheet" href="/static/css/print.css" media="print">
<!-- Fonts with Preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&family=Source+Sans+Pro:wght@300;400;600&display=swap" rel="stylesheet">
<!-- Structured Data (JSON-LD) - Enhanced for AI-era SEO -->
<!-- Person Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"@id": "{{.CV.Personal.Website}}/#person",
"name": "{{.CV.Personal.Name}}",
"givenName": "{{.CV.Personal.FirstName}}",
"familyName": "{{.CV.Personal.LastName}}",
"jobTitle": "{{.CV.Personal.Title}}",
"description": "{{.CV.Summary}}",
"url": "{{.CV.Personal.Website}}",
"image": "{{.CV.Personal.Website}}/static/images/profile.jpg",
"email": "{{.CV.Personal.Email}}",
"telephone": "{{.CV.Personal.Phone}}",
"birthDate": "{{.CV.Personal.DateOfBirth}}",
"birthPlace": {
"@type": "Place",
"name": "{{.CV.Personal.PlaceOfBirth}}"
},
"nationality": {
"@type": "Country",
"name": "{{.CV.Personal.Citizenship}}"
},
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.CV.Personal.Location}}",
"addressCountry": "ES"
},
"sameAs": [
"{{.CV.Personal.LinkedIn}}",
"{{.CV.Personal.GitHub}}",
"{{.CV.Personal.Domestika}}"
],
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "Universidad de Extremadura",
"address": {
"@type": "PostalAddress",
"addressLocality": "Cáceres",
"addressCountry": "ES"
}
},
"knowsAbout": [
"Web Development",
"SAP Customer Data Cloud",
"React",
"Node.js",
"Go",
"HTMX",
"AI-Assisted Development",
"Full Stack Development",
"Authentication Systems",
"GDPR Compliance",
"Identity Management"
],
"knowsLanguage": [
{
"@type": "Language",
"name": "Spanish",
"alternateName": "es"
},
{
"@type": "Language",
"name": "English",
"alternateName": "en"
},
{
"@type": "Language",
"name": "Portuguese",
"alternateName": "pt"
}
],
"worksFor": [
{
"@type": "Organization",
"name": "Olympic Broadcasting Services",
"url": "https://www.obs.tv/"
},
{
"@type": "Organization",
"name": "LIV Golf",
"url": "https://www.livgolf.com/"
}
],
"hasOccupation": [
{{- range $i, $exp := .CV.Experience}}{{if $i}},{{end}}
{
"@type": "Occupation",
"name": "{{$exp.Position}}",
"occupationLocation": {
"@type": "Place",
"name": "{{$exp.Location}}"
},
"description": "{{$exp.ShortDescription}}",
"skills": "{{range $j, $tech := $exp.Technologies}}{{if $j}}, {{end}}{{$tech}}{{end}}"
}
{{- end}}
]
}
</script>
<!-- WebSite Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "{{.CV.Personal.Website}}/#website",
"name": "{{.CV.Personal.Name}} - Professional CV",
"url": "{{.CV.Personal.Website}}",
"description": "Interactive curriculum vitae of {{.CV.Personal.Name}}, {{.CV.Personal.Title}}",
"inLanguage": ["en", "es"],
"author": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"potentialAction": {
"@type": "SearchAction",
"target": "{{.CV.Personal.Website}}/?lang={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "{{.CV.Personal.Website}}"
},
{
"@type": "ListItem",
"position": 2,
"name": "CV {{if eq .Lang "es"}}(Español){{else}}(English){{end}}",
"item": "{{.CV.Personal.Website}}/?lang={{.Lang}}"
}
]
}
</script>
<!-- ProfilePage Schema (for CV/Resume) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"dateCreated": "2024-01-01",
"dateModified": "{{.CV.Meta.LastUpdated}}",
"name": "{{.CV.Personal.Name}} - Curriculum Vitae",
"description": "{{.CV.SEO.MetaDescription}}",
"inLanguage": "{{.Lang}}"
}
</script>
<!-- EducationalOccupationalCredential Schema -->
{{range .CV.Education}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "EducationalOccupationalCredential",
"name": "{{.Degree}}",
"description": "{{.Field}}",
"educationalLevel": "Bachelor's Degree",
"credentialCategory": "degree",
"recognizedBy": {
"@type": "CollegeOrUniversity",
"name": "{{.Institution}}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.Location}}"
}
},
"dateCreated": "{{.EndDate}}"
}
</script>
{{end}}
<!-- Course Schemas -->
{{range .CV.Courses}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Course",
"name": "{{.Title}}",
"description": "{{if .ShortDescription}}{{.ShortDescription}}{{else}}{{.Description}}{{end}}",
"provider": {
"@type": "Organization",
"name": "{{.Institution}}"
},
"hasCourseInstance": {
"@type": "CourseInstance",
"courseMode": "onsite",
"location": {
"@type": "Place",
"name": "{{.Location}}"
}
},
"timeRequired": "{{.Duration}}"
}
</script>
{{end}}
</head>
{{template "head" .}}
<body {{if .ThemeClean}}class="theme-clean"{{end}}
_="on load call initScrollBehavior()
on scroll from window call handleScroll()
@@ -351,29 +15,35 @@
if (event.key is 'i' or event.key is 'I') and noMod and not skip then halt the event then call handleToggleShortcut('iconToggle', 'iconToggleMenu') end
if (event.key is 'v' or event.key is 'V') and noMod and not skip then halt the event then call handleToggleShortcut('themeToggle', 'themeToggleMenu') end
end">
<!-- Top anchor for back-to-top link -->
<div id="top"></div>
<!-- ============================================ -->
<!-- TOP NAVIGATION & CONTROLS -->
<!-- ============================================ -->
<div id="top"></div>
{{template "action-bar" .}}
{{template "hamburger-menu" .}}
{{template "color-theme-switcher" .}}
<!-- Zoom Wrapper (for zoom functionality) -->
<!-- ============================================ -->
<!-- MAIN CV CONTENT -->
<!-- ============================================ -->
<div id="zoom-wrapper" class="zoom-wrapper">
<!-- CV Content Container -->
<div class="cv-container">
{{template "cv-content.html" .}}
</div>
</div> <!-- End zoom-wrapper -->
</div>
<!-- ============================================ -->
<!-- PAGE FOOTER & NOTIFICATIONS -->
<!-- ============================================ -->
{{template "page-footer" .}}
{{template "error-toast" .}}
{{template "pdf-toast" .}}
<!-- iOS-style blur backdrop for mobile buttons -->
<!-- ============================================ -->
<!-- FLOATING BUTTONS -->
<!-- ============================================ -->
<div class="fixed-buttons-backdrop no-print"></div>
{{template "back-to-top" .}}
{{template "info-button" .}}
{{template "download-button" .}}
@@ -382,35 +52,19 @@
{{template "zoom-toggle-button" .}}
{{template "shortcuts-button" .}}
{{template "cmd-k-button" .}}
<!-- ============================================ -->
<!-- MODALS -->
<!-- ============================================ -->
{{template "info-modal" .}}
{{template "shortcuts-modal" .}}
{{template "pdf-modal" .}}
{{template "contact-modal" .}}
{{template "zoom-control" .}}
<!-- CMD+K Command Bar -->
<ninja-keys id="cmd-k-bar" placeholder="Type a command or search..." hideBreadcrumbs openHotkey="cmd+k,ctrl+k"></ninja-keys>
<!-- External JavaScript - CSP Compliant -->
<script src="/static/js/main.js"></script>
<script src="/static/js/ninja-keys-init.js"></script>
<!-- Matomo Analytics - First-party subdomain to bypass ad blockers -->
<script>
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="https://matomo.morenorub.io/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '4']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true;
g.src=u+'matomo.js';
s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
<!-- ============================================ -->
<!-- SCRIPTS & ANALYTICS -->
<!-- ============================================ -->
{{template "body-scripts" .}}
</body>
</html>
@@ -0,0 +1,93 @@
{{define "body-scripts"}}
<!-- CMD+K Command Bar - Lazy loaded placeholder -->
<div id="cmd-k-container"></div>
<!-- External JavaScript - CSP Compliant -->
<script src="/static/js/main.js"></script>
<!-- Ninja Keys Lazy Loader - Only loads when CMD+K is pressed -->
<script>
(function() {
let ninjaLoaded = false;
let ninjaLoading = false;
async function loadNinjaKeys() {
if (ninjaLoaded || ninjaLoading) return;
ninjaLoading = true;
// Use esm.sh with bundle option to reduce module requests
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
// Create the ninja-keys element
const container = document.getElementById('cmd-k-container');
const ninjaKeys = document.createElement('ninja-keys');
ninjaKeys.id = 'cmd-k-bar';
ninjaKeys.placeholder = 'Type a command or search...';
ninjaKeys.hideBreadcrumbs = true;
container.appendChild(ninjaKeys);
// Load the initialization script
const script = document.createElement('script');
script.src = '/static/js/ninja-keys-init.js';
document.body.appendChild(script);
ninjaLoaded = true;
ninjaLoading = false;
// Open the palette after a brief delay for initialization
setTimeout(() => {
if (ninjaKeys.open) ninjaKeys.open();
}, 100);
}
function openNinjaKeys() {
const nk = document.getElementById('cmd-k-bar');
if (nk && typeof nk.open === 'function') {
nk.open();
}
}
// Listen for CMD+K / Ctrl+K
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (ninjaLoaded) {
openNinjaKeys();
} else {
loadNinjaKeys();
}
}
});
// Also handle click on cmd-k button
document.addEventListener('click', function(e) {
if (e.target.closest('#cmd-k-button, .cmd-k-trigger')) {
e.preventDefault();
if (ninjaLoaded) {
openNinjaKeys();
} else {
loadNinjaKeys();
}
}
});
})();
</script>
<!-- Matomo Analytics - First-party subdomain to bypass ad blockers -->
<script>
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="https://matomo.morenorub.io/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '4']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true;
g.src=u+'matomo.js';
s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
{{end}}
@@ -0,0 +1,7 @@
{{define "head-fonts"}}
<!-- Fonts with Preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&family=Source+Sans+Pro:wght@300;400;600&display=swap" rel="stylesheet">
{{end}}
@@ -0,0 +1,40 @@
{{define "head-fouc-prevention"}}
<!-- FOUC Prevention: Inline critical CSS + Apply color theme before page render -->
<!-- Critical theme variables inlined to prevent flash of unstyled content -->
<style>
/* Light theme (default) - critical variables only */
:root {
--page-bg: #d6d6d6;
--paper-bg: #ffffff;
--text-primary: #1a1a1a;
--sidebar-bg: #d1d4d2;
}
/* Dark theme - critical variables only */
[data-color-theme="dark"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
/* Auto theme follows system preference */
@media (prefers-color-scheme: dark) {
[data-color-theme="auto"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
}
/* Apply critical styles immediately */
html { background-color: var(--page-bg); }
body { color: var(--text-primary); }
</style>
<script>
(function() {
// First-time visitors ALWAYS get light theme (paper aesthetic)
// Users can switch to dark/auto and their preference is saved
let theme = localStorage.getItem('color-theme-mode') || 'light';
document.documentElement.setAttribute('data-color-theme', theme);
})();
</script>
{{end}}
@@ -0,0 +1,34 @@
{{define "head-scripts"}}
<!-- Device Detection - Detect real mobile devices vs desktop browser -->
<script src="/static/js/device-detection.js"></script>
<!-- HTMX with SRI (Subresource Integrity) -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- Hyperscript Functions - Must load BEFORE hyperscript library -->
<!-- NOTE: cv-functions.js removed - hyperscript def statements are globally available -->
<!-- ✅ NO def limit with latest hyperscript - organized by category -->
<script type="text/hyperscript" src="/static/hyperscript/utils._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/toggles._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/hover-sync._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/keyboard._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/zoom._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/pdf-modal._hs"></script>
<!-- Color Theme System (JavaScript - hyperscript had parsing issues with colons in strings) -->
<script src="/static/js/color-theme.js"></script>
<!-- NOTE: footer-buttons-interaction.js removed - moved to hyperscript on footer element -->
<!-- NOTE: scroll-at-bottom-handler.js removed - duplicate of handleScroll() in utils._hs -->
<!-- Hyperscript - Declarative event handling for enhanced interactivity -->
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
<!-- Ninja Keys - Lazy loaded on CMD+K (see body-scripts for loader) -->
<!-- Iconify - Load synchronously for immediate rendering -->
<!-- Using unpkg CDN (more reliable than code.iconify.design) -->
<script src="https://cdn.jsdelivr.net/npm/iconify-icon@2.1.0/dist/iconify-icon.min.js"></script>
{{end}}
@@ -0,0 +1,211 @@
{{define "head-structured-data"}}
<!-- Structured Data (JSON-LD) - Enhanced for AI-era SEO -->
<!-- Person Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"@id": "{{.CV.Personal.Website}}/#person",
"name": "{{.CV.Personal.Name}}",
"givenName": "{{.CV.Personal.FirstName}}",
"familyName": "{{.CV.Personal.LastName}}",
"jobTitle": "{{.CV.Personal.Title}}",
"description": "{{.CV.Summary}}",
"url": "{{.CV.Personal.Website}}",
"image": "{{.CV.Personal.Website}}/static/images/profile.jpg",
"email": "{{.CV.Personal.Email}}",
"telephone": "{{.CV.Personal.Phone}}",
"birthDate": "{{.CV.Personal.DateOfBirth}}",
"birthPlace": {
"@type": "Place",
"name": "{{.CV.Personal.PlaceOfBirth}}"
},
"nationality": {
"@type": "Country",
"name": "{{.CV.Personal.Citizenship}}"
},
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.CV.Personal.Location}}",
"addressCountry": "ES"
},
"sameAs": [
"{{.CV.Personal.LinkedIn}}",
"{{.CV.Personal.GitHub}}",
"{{.CV.Personal.Domestika}}"
],
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "Universidad de Extremadura",
"address": {
"@type": "PostalAddress",
"addressLocality": "Cáceres",
"addressCountry": "ES"
}
},
"knowsAbout": [
"Web Development",
"SAP Customer Data Cloud",
"React",
"Node.js",
"Go",
"HTMX",
"AI-Assisted Development",
"Full Stack Development",
"Authentication Systems",
"GDPR Compliance",
"Identity Management"
],
"knowsLanguage": [
{
"@type": "Language",
"name": "Spanish",
"alternateName": "es"
},
{
"@type": "Language",
"name": "English",
"alternateName": "en"
},
{
"@type": "Language",
"name": "Portuguese",
"alternateName": "pt"
}
],
"worksFor": [
{
"@type": "Organization",
"name": "Olympic Broadcasting Services",
"url": "https://www.obs.tv/"
},
{
"@type": "Organization",
"name": "LIV Golf",
"url": "https://www.livgolf.com/"
}
],
"hasOccupation": [
{{- range $i, $exp := .CV.Experience}}{{if $i}},{{end}}
{
"@type": "Occupation",
"name": "{{$exp.Position}}",
"occupationLocation": {
"@type": "Place",
"name": "{{$exp.Location}}"
},
"description": "{{$exp.ShortDescription}}",
"skills": "{{range $j, $tech := $exp.Technologies}}{{if $j}}, {{end}}{{$tech}}{{end}}"
}
{{- end}}
]
}
</script>
<!-- WebSite Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "{{.CV.Personal.Website}}/#website",
"name": "{{.CV.Personal.Name}} - Professional CV",
"url": "{{.CV.Personal.Website}}",
"description": "Interactive curriculum vitae of {{.CV.Personal.Name}}, {{.CV.Personal.Title}}",
"inLanguage": ["en", "es"],
"author": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"potentialAction": {
"@type": "SearchAction",
"target": "{{.CV.Personal.Website}}/?lang={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "{{.CV.Personal.Website}}"
},
{
"@type": "ListItem",
"position": 2,
"name": "CV {{if eq .Lang "es"}}(Español){{else}}(English){{end}}",
"item": "{{.CV.Personal.Website}}/?lang={{.Lang}}"
}
]
}
</script>
<!-- ProfilePage Schema (for CV/Resume) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"dateCreated": "2024-01-01",
"dateModified": "{{.CV.Meta.LastUpdated}}",
"name": "{{.CV.Personal.Name}} - Curriculum Vitae",
"description": "{{.CV.SEO.MetaDescription}}",
"inLanguage": "{{.Lang}}"
}
</script>
<!-- EducationalOccupationalCredential Schema -->
{{range .CV.Education}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "EducationalOccupationalCredential",
"name": "{{.Degree}}",
"description": "{{.Field}}",
"educationalLevel": "Bachelor's Degree",
"credentialCategory": "degree",
"recognizedBy": {
"@type": "CollegeOrUniversity",
"name": "{{.Institution}}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.Location}}"
}
},
"dateCreated": "{{.EndDate}}"
}
</script>
{{end}}
<!-- Course Schemas -->
{{range .CV.Courses}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Course",
"name": "{{.Title}}",
"description": "{{if .ShortDescription}}{{.ShortDescription}}{{else}}{{.Description}}{{end}}",
"provider": {
"@type": "Organization",
"name": "{{.Institution}}"
},
"hasCourseInstance": {
"@type": "CourseInstance",
"courseMode": "onsite",
"location": {
"@type": "Place",
"name": "{{.Location}}"
}
},
"timeRequired": "{{.Duration}}"
}
</script>
{{end}}
{{end}}
@@ -0,0 +1,10 @@
{{define "head-styles"}}
<!-- CSS - Conditional loading: bundled in production, modular in development -->
{{if .IsProduction}}
<link rel="stylesheet" href="/static/dist/bundle.min.css">
{{else}}
<link rel="stylesheet" href="/static/css/main.css">
{{end}}
<!-- Print styles - loaded separately, only applied when printing -->
<link rel="stylesheet" href="/static/css/print.css" media="print">
{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "head"}}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<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}}">
<!-- Hreflang tags for international SEO -->
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}">
<link rel="alternate" hreflang="es" href="{{.AlternateES}}">
<link rel="alternate" hreflang="x-default" href="https://juan.andres.morenorub.io/?lang=en">
<!-- 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}} - {{.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="{{.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}} - {{.CV.SEO.MetaTitle}}">
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<!-- HTMX Configuration -->
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
{{template "head-fouc-prevention" .}}
{{template "head-scripts" .}}
{{template "head-styles" .}}
{{template "head-fonts" .}}
{{template "head-structured-data" .}}
</head>
{{end}}
+1 -1
View File
@@ -17,7 +17,7 @@
if responseDiv set responseDiv.innerHTML to ''
end">
<div class="info-modal-content">
<button class="info-modal-close" onclick="document.getElementById('contact-modal').close()" aria-label="{{.UI.ContactModal.Close}}">
<button class="info-modal-close" commandfor="contact-modal" command="close" aria-label="{{.UI.ContactModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
+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="{{.UI.PdfModal.Close}}">
<button class="info-modal-close" commandfor="info-modal" command="close" aria-label="{{.UI.PdfModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
+2 -1
View File
@@ -19,7 +19,8 @@
<!-- Close Button -->
<button class="info-modal-close"
onclick="document.getElementById('pdf-modal').close()"
commandfor="pdf-modal"
command="close"
aria-label="{{.UI.PdfModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
@@ -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="{{.UI.ShortcutsModal.Close}}">
<button class="info-modal-close" commandfor="shortcuts-modal" command="close" aria-label="{{.UI.ShortcutsModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
@@ -4,7 +4,8 @@
<button
id="action-bar-pdf-btn"
class="action-btn pdf-btn has-tooltip"
onclick="document.getElementById('pdf-modal').showModal()"
commandfor="pdf-modal"
command="show-modal"
aria-label="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
data-tooltip="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
_="on mouseenter call syncPdfHover(true)
@@ -23,10 +24,9 @@
{{.UI.Widgets.ActionButtons.PrintFriendly}}
</button>
<button
class="action-btn search-btn has-tooltip"
class="action-btn search-btn has-tooltip cmd-k-trigger"
aria-label="{{.UI.Widgets.ActionButtons.SearchAriaLabel}}"
data-tooltip="{{.UI.Widgets.ActionButtons.Search}}"
_="on click set #cmd-k-bar's @open to true">
data-tooltip="{{.UI.Widgets.ActionButtons.Search}}">
<iconify-icon icon="mdi:magnify" width="24" height="24"></iconify-icon>
{{.UI.Widgets.ActionButtons.Search}}
</button>
@@ -177,7 +177,8 @@
</div>
<button class="menu-action-btn menu-pdf-btn"
onclick="document.getElementById('pdf-modal').showModal()"
commandfor="pdf-modal"
command="show-modal"
_="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)">
<iconify-icon icon="catppuccin:pdf" width="20" height="20"></iconify-icon>
@@ -193,7 +194,8 @@
</button>
<button class="menu-action-btn menu-contact-btn"
onclick="document.getElementById('contact-modal').showModal()">
commandfor="contact-modal"
command="show-modal">
<iconify-icon icon="mdi:email-outline" width="20" height="20"></iconify-icon>
<span>{{.UI.Widgets.ActionButtons.Contact}}</span>
</button>
+3 -3
View File
@@ -1,11 +1,11 @@
{{define "cmd-k-button"}}
<!-- CMD+K Command Bar Button (Fixed Left - Last) -->
<!-- Uses lazy loading - ninja-keys loads on first click -->
<button
id="cmd-k-button"
class="fixed-btn cmd-k-btn no-print has-tooltip"
class="fixed-btn cmd-k-btn no-print has-tooltip cmd-k-trigger"
aria-label="{{.UI.CmdK.Button.AriaLabel}}"
data-tooltip="{{.UI.CmdK.Button.Tooltip}}"
_="on click set #cmd-k-bar's @open to true">
data-tooltip="{{.UI.CmdK.Button.Tooltip}}">
<iconify-icon icon="mdi:text-box-search-outline"></iconify-icon>
</button>
{{end}}
@@ -1,9 +1,11 @@
{{define "contact-button"}}
<!-- Contact Button (Fixed Left) -->
<!-- Uses HTML Invoker Commands API: commandfor + command="show-modal" -->
<button
id="contact-button"
class="fixed-btn contact-btn no-print has-tooltip"
onclick="document.getElementById('contact-modal').showModal()"
commandfor="contact-modal"
command="show-modal"
aria-label="{{.UI.Widgets.Contact.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Contact.Tooltip}}">
<iconify-icon icon="mdi:email-outline"></iconify-icon>
+3 -1
View File
@@ -1,9 +1,11 @@
{{define "info-button"}}
<!-- Info Button (Bottom Left) -->
<!-- Uses HTML Invoker Commands API: commandfor + command="show-modal" -->
<button id="info-button" class="info-button no-print has-tooltip"
aria-label="{{.UI.Widgets.Info.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Info.Tooltip}}"
onclick="document.getElementById('info-modal').showModal()">
commandfor="info-modal"
command="show-modal">
<iconify-icon icon="mdi:information-outline"></iconify-icon>
</button>
{{end}}
@@ -1,9 +1,11 @@
{{define "shortcuts-button"}}
<!-- Keyboard Shortcuts Button (Fixed Left) -->
<!-- Uses HTML Invoker Commands API: commandfor + command="show-modal" -->
<button
id="shortcuts-button"
class="fixed-btn shortcuts-btn no-print has-tooltip"
onclick="document.getElementById('shortcuts-modal').showModal()"
commandfor="shortcuts-modal"
command="show-modal"
aria-label="{{.UI.Widgets.Shortcuts.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Shortcuts.Tooltip}}">
<iconify-icon icon="mdi:keyboard-outline"></iconify-icon>