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
+502
View File
@@ -0,0 +1,502 @@
# HTMX Learning Guide
**Last Updated**: December 2024
## Overview
This document explains HTMX patterns used in this CV website project, with practical examples from the codebase. Use this as a learning resource for understanding HTMX concepts.
## Table of Contents
1. [Core Concepts](#core-concepts)
2. [Out-of-Band Swaps (OOB)](#out-of-band-swaps-oob)
3. [Language Switch Pattern](#language-switch-pattern)
4. [Toggle Patterns](#toggle-patterns)
5. [Contact Form Pattern](#contact-form-pattern)
6. [Skeleton Loaders](#skeleton-loaders)
7. [Common Attributes Reference](#common-attributes-reference)
---
## Core Concepts
### What is HTMX?
HTMX allows you to build modern user interfaces with simple HTML attributes instead of JavaScript. It extends HTML with attributes that enable:
- **AJAX requests** from any element (not just links/forms)
- **Partial page updates** without full page reloads
- **CSS transitions** on swaps
- **WebSocket/SSE** support
### Basic Example
```html
<!-- Button that fetches content and replaces a target -->
<button hx-get="/api/content"
hx-target="#content-area"
hx-swap="innerHTML">
Load Content
</button>
<div id="content-area">
<!-- Content will be replaced here -->
</div>
```
---
## Out-of-Band Swaps (OOB)
### The Problem
Normal HTMX swaps can only update ONE target element. But what if you need to update MULTIPLE elements with a single request?
**Example**: When switching languages, we need to update:
- Language selector buttons (show which is active)
- Page 1 content (header, experience, education)
- Page 2 content (awards, projects, courses)
- Footer
### The Solution: `hx-swap-oob`
OOB (Out-of-Band) swaps let you update ANY element on the page by including it in your response with a matching `id`.
```html
<!-- Server response can include multiple elements -->
<!-- Main response goes to hx-target -->
<div>Main content here</div>
<!-- OOB elements update by matching ID -->
<div id="sidebar" hx-swap-oob="outerHTML">
New sidebar content
</div>
<div id="notification" hx-swap-oob="innerHTML">
5 new messages
</div>
```
### OOB Swap Types
| Attribute | Effect |
|-----------|--------|
| `hx-swap-oob="true"` | Replace inner HTML |
| `hx-swap-oob="innerHTML"` | Replace inner HTML |
| `hx-swap-oob="outerHTML"` | Replace entire element including itself |
| `hx-swap-oob="beforebegin"` | Insert before element |
| `hx-swap-oob="afterend"` | Insert after element |
---
## Language Switch Pattern
**File**: `templates/language-switch.html`
This is the most complex HTMX pattern in the project - updating the entire CV content when switching languages.
### How It Works
1. **User clicks language button** (EN or ES)
2. **HTMX sends request** to `/switch-language?lang=es`
3. **Server renders new content** for both pages in the selected language
4. **OOB swaps update** both page containers atomically
### The Template Structure
```html
<!-- templates/language-switch.html -->
<!-- First: Update language selector buttons (OOB) -->
<div id="language-selector-container" hx-swap-oob="outerHTML">
<button class="lang-btn {{if eq .Lang "en"}}active{{end}}"
hx-post="/switch-language?lang=en">EN</button>
<button class="lang-btn {{if eq .Lang "es"}}active{{end}}"
hx-post="/switch-language?lang=es">ES</button>
</div>
<!-- Second: Update Page 1 content (OOB) -->
<div id="cv-inner-content-page-1"
class="cv-page-content-wrapper"
hx-swap-oob="outerHTML"
_="on htmx:afterSettle wait 100ms then remove .loading from me">
{{template "title-badges" .}}
<div class="page-content">
<!-- Left Sidebar -->
<aside class="cv-sidebar cv-sidebar-left">
{{range .SkillsLeft}}
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
{{end}}
</aside>
<!-- Main Content -->
<main class="cv-main">
{{template "section-header" .}}
{{template "section-education" .}}
{{template "section-experience" .}}
</main>
</div>
</div>
<!-- Third: Update Page 2 content (OOB) -->
<div id="cv-inner-content-page-2"
class="cv-page-content-wrapper"
hx-swap-oob="outerHTML"
_="on htmx:afterSettle wait 100ms then remove .loading from me">
{{template "title-badges" .}}
<div class="page-content">
<!-- Main Content -->
<main class="cv-main">
{{template "section-awards" .}}
{{template "section-projects" .}}
{{template "section-courses" .}}
</main>
<!-- Right Sidebar -->
<aside class="cv-sidebar cv-sidebar-right">
{{range .SkillsRight}}
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
{{end}}
</aside>
</div>
{{template "cv-footer" .}}
</div>
```
### Why Two Separate Page Divs?
The CV is designed as a **2-page printable document**:
| Page 1 | Page 2 |
|--------|--------|
| Header | Awards |
| Education | Projects |
| Skills Summary | Courses |
| Experience | Languages |
| Left Sidebar (Skills) | References |
| | Right Sidebar (More Skills) |
| | Footer |
Each page has its own layout grid and sidebar positioning, so they need separate containers.
### The Hyperscript Integration
```html
_="on htmx:afterSettle wait 100ms then remove .loading from me"
```
This hyperscript:
1. Listens for `htmx:afterSettle` event (content fully swapped)
2. Waits 100ms (for CSS transitions)
3. Removes `.loading` class (hides skeleton, shows content)
### Visual Flow
```
┌─────────────────────────────────────────────────────────┐
│ User clicks "ES" │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ hx-post="/switch-language?lang=es" │
│ hx-target="#cv-content" │
│ hx-swap="none" ← Response body ignored for main swap │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Server Response Contains: │
│ │
│ 1. Language selector (hx-swap-oob="outerHTML") │
│ 2. Page 1 content (hx-swap-oob="outerHTML") │
│ 3. Page 2 content (hx-swap-oob="outerHTML") │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ HTMX matches IDs and swaps all three simultaneously │
│ → Atomic update, no flicker │
└─────────────────────────────────────────────────────────┘
```
---
## Toggle Patterns
### Pattern: `hx-swap="none"` + Cookies
Toggles (length, theme, icons) don't need server-rendered content because:
1. The server just needs to **set a cookie**
2. The frontend uses **hyperscript** to toggle UI state
```html
<!-- Toggle button -->
<button hx-post="/toggle/length"
hx-swap="none"
_="on htmx:afterRequest toggle .cv-short .cv-long on #cv-container">
Toggle Length
</button>
```
### Server Handler
```go
// Returns 204 No Content - body is ignored anyway
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
// Toggle cookie value
prefs := middleware.GetPreferences(r)
newLength := "long"
if prefs.CVLength == "long" {
newLength = "short"
}
// Set cookie
middleware.SetPreferenceCookie(w, "cv-length", newLength)
// Return 204 - no body needed
w.WriteHeader(http.StatusNoContent)
}
```
### Why This Works
1. `hx-swap="none"` tells HTMX to **ignore the response body**
2. The **cookie gets set** (browser handles this automatically)
3. **Hyperscript handles UI** changes locally
4. On **next page load**, server reads cookie and renders correctly
---
## Contact Form Pattern
**File**: `templates/partials/contact/contact-form.html`
### Pattern: `hx-target` + Partial Replacement
```html
<form hx-post="/api/contact"
hx-target="#contact-form-container"
hx-swap="innerHTML"
hx-indicator="#contact-spinner">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<button type="submit">
Send
<span id="contact-spinner" class="htmx-indicator">...</span>
</button>
</form>
```
### Server Responses
**Success**: Returns success partial
```html
<!-- templates/partials/contact/contact-success.html -->
<div class="contact-success">
<iconify-icon icon="mdi:check-circle"></iconify-icon>
<p>Message sent successfully!</p>
</div>
```
**Error**: Returns error partial
```html
<!-- templates/partials/contact/contact-error.html -->
<div class="contact-error">
<iconify-icon icon="mdi:alert-circle"></iconify-icon>
<p>{{.ErrorMessage}}</p>
</div>
```
### The `hx-indicator` Pattern
```html
hx-indicator="#contact-spinner"
```
HTMX automatically:
1. Adds `.htmx-request` class to indicator during request
2. Shows spinner via CSS: `.htmx-indicator { display: none } .htmx-request .htmx-indicator { display: inline }`
---
## Skeleton Loaders
### Pattern: CSS Class Toggle with Hyperscript
The skeleton loader system uses a **dual-state structure**:
```html
<div class="component-wrapper">
<!-- Real content - visible by default -->
<div class="actual-content">
<h1>John Smith</h1>
<p>Software Engineer</p>
</div>
<!-- Skeleton - hidden by default -->
<div class="skeleton-content">
<div class="skeleton skeleton-name"></div>
<div class="skeleton skeleton-text"></div>
</div>
</div>
```
### CSS State Control
```css
/* Default: Show content, hide skeleton */
.component-wrapper .actual-content { opacity: 1; }
.component-wrapper .skeleton-content { opacity: 0; pointer-events: none; }
/* Loading: Hide content, show skeleton */
.component-wrapper.loading .actual-content { opacity: 0; }
.component-wrapper.loading .skeleton-content { opacity: 1; }
```
### Triggering Loading State
Before language switch:
```javascript
// Add .loading to show skeletons
document.querySelectorAll('.cv-page-content-wrapper').forEach(el => {
el.classList.add('loading');
});
```
After content loads (via hyperscript):
```html
_="on htmx:afterSettle wait 100ms then remove .loading from me"
```
---
## Common Attributes Reference
### Request Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `hx-get` | GET request | `hx-get="/api/data"` |
| `hx-post` | POST request | `hx-post="/api/submit"` |
| `hx-put` | PUT request | `hx-put="/api/update/1"` |
| `hx-delete` | DELETE request | `hx-delete="/api/item/1"` |
### Target & Swap Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `hx-target` | Element to update | `hx-target="#results"` |
| `hx-swap` | How to swap content | `hx-swap="innerHTML"` |
| `hx-swap-oob` | Out-of-band swap | `hx-swap-oob="outerHTML"` |
### Swap Values
| Value | Effect |
|-------|--------|
| `innerHTML` | Replace inner HTML (default) |
| `outerHTML` | Replace entire element |
| `beforebegin` | Insert before element |
| `afterbegin` | Insert at start of element |
| `beforeend` | Insert at end of element |
| `afterend` | Insert after element |
| `none` | Don't swap (use for side effects only) |
### Trigger Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `hx-trigger` | Event to trigger request | `hx-trigger="click"` |
| `hx-indicator` | Loading indicator | `hx-indicator="#spinner"` |
| `hx-confirm` | Confirmation dialog | `hx-confirm="Are you sure?"` |
### Special Triggers
```html
<!-- Trigger on input with 500ms debounce -->
hx-trigger="input changed delay:500ms"
<!-- Trigger on scroll into view -->
hx-trigger="revealed"
<!-- Trigger on page load -->
hx-trigger="load"
<!-- Trigger on intersection observer -->
hx-trigger="intersect"
```
---
## Best Practices Learned
### 1. Use OOB for Multi-Element Updates
Instead of multiple requests, use OOB swaps:
```html
<!-- Server returns multiple elements in one response -->
<div id="main-content">Updated main</div>
<div id="sidebar" hx-swap-oob="outerHTML">Updated sidebar</div>
<div id="notification" hx-swap-oob="innerHTML">New notification</div>
```
### 2. Use `hx-swap="none"` for Side Effects
When you only need server-side effects (cookies, database), skip the swap:
```html
<button hx-post="/api/favorite"
hx-swap="none"
_="on htmx:afterRequest toggle .favorited on me">
```
### 3. Combine HTMX + Hyperscript
HTMX handles server communication; hyperscript handles local UI:
```html
<button hx-post="/toggle/theme"
hx-swap="none"
_="on htmx:afterRequest
toggle .dark-theme .light-theme on body">
```
### 4. Use CSS for Loading States
Instead of JavaScript spinners:
```html
<button hx-indicator=".spinner">
Submit <span class="spinner htmx-indicator">...</span>
</button>
```
```css
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
```
### 5. Return Proper HTTP Status Codes
- `200 OK` - Success with content
- `204 No Content` - Success, no body needed
- `422 Unprocessable Entity` - Validation errors
- HTMX handles these gracefully
---
## Further Reading
- [HTMX Documentation](https://htmx.org/docs/)
- [Hyperscript Documentation](https://hyperscript.org/docs/)
- [HTMX Examples](https://htmx.org/examples/)
- Project doc: `doc/2-MODERN-WEB-TECHNIQUES.md` - Full techniques reference
- Project doc: `doc/4-HYPERSCRIPT-RULES.md` - Hyperscript patterns
+1 -1
View File
@@ -30,7 +30,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
// Content Security Policy (comprehensive)
csp := "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://matomo.morenorub.io; " +
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://matomo.morenorub.io; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https:; " +
+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>
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bun
/**
* HTML INVOKER COMMANDS API TEST
* ==============================
* Tests the new HTML commandfor/command attributes for modals:
* - Buttons have commandfor and command attributes
* - command="show-modal" opens dialogs
* - command="close" closes dialogs
* - No onclick handlers for modal operations
*
* Browser support: Chrome/Edge 135+, Firefox Nightly, Safari TP
* @see https://developer.chrome.com/blog/command-and-commandfor
*/
import { chromium } from 'playwright';
const URL = "http://localhost:1999";
async function testInvokerCommands() {
console.log('🎯 HTML INVOKER COMMANDS API TEST\n');
console.log('='.repeat(70));
const browser = await chromium.launch({ headless: process.env.HEADLESS === 'true' });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
const testResults = [];
await page.goto(URL);
await page.waitForTimeout(2000);
// ========================================================================
// TEST 1: Buttons have commandfor and command attributes
// ========================================================================
console.log('\n1️⃣ Testing buttons have commandfor/command attributes...');
const buttonsWithCommand = await page.evaluate(() => {
const buttons = document.querySelectorAll('[commandfor]');
return Array.from(buttons).map(btn => ({
id: btn.id || btn.className.split(' ')[0],
commandfor: btn.getAttribute('commandfor'),
command: btn.getAttribute('command'),
hasOnclick: btn.hasAttribute('onclick')
}));
});
console.log(` Found ${buttonsWithCommand.length} buttons with commandfor attribute:`);
buttonsWithCommand.forEach(btn => {
console.log(` - ${btn.id}: commandfor="${btn.commandfor}" command="${btn.command}" onclick=${btn.hasOnclick}`);
});
const hasCommandButtons = buttonsWithCommand.length >= 6; // At least 6 modal buttons
console.log(` ${hasCommandButtons ? '✅ PASS' : '❌ FAIL'} - At least 6 buttons use commandfor`);
testResults.push({ test: 'Buttons have commandfor attributes', passed: hasCommandButtons });
// ========================================================================
// TEST 2: No onclick for showModal/close
// ========================================================================
console.log('\n2️⃣ Testing no onclick handlers for modal operations...');
const noOnclickForModals = buttonsWithCommand.every(btn => !btn.hasOnclick);
console.log(` All command buttons without onclick: ${noOnclickForModals}`);
console.log(` ${noOnclickForModals ? '✅ PASS' : '❌ FAIL'} - No onclick handlers on command buttons`);
testResults.push({ test: 'No onclick on command buttons', passed: noOnclickForModals });
// ========================================================================
// TEST 3: Info button opens info-modal
// ========================================================================
console.log('\n3️⃣ Testing info button opens modal via command attribute...');
const infoButton = await page.$('#info-button');
if (infoButton) {
const infoAttrs = await page.$eval('#info-button', el => ({
commandfor: el.getAttribute('commandfor'),
command: el.getAttribute('command')
}));
console.log(` Info button: commandfor="${infoAttrs.commandfor}" command="${infoAttrs.command}"`);
// Click the button
await infoButton.click();
await page.waitForTimeout(500);
const infoModalOpen = await page.evaluate(() => {
const modal = document.getElementById('info-modal');
return modal && modal.hasAttribute('open');
});
console.log(` Info modal opened: ${infoModalOpen}`);
console.log(` ${infoModalOpen ? '✅ PASS' : '❌ FAIL'} - command="show-modal" works`);
testResults.push({ test: 'command="show-modal" opens dialog', passed: infoModalOpen });
// Test close button
if (infoModalOpen) {
const closeButton = await page.$('#info-modal [command="close"]');
if (closeButton) {
await closeButton.click();
await page.waitForTimeout(300);
const modalClosed = await page.evaluate(() => {
const modal = document.getElementById('info-modal');
return modal && !modal.hasAttribute('open');
});
console.log(` Info modal closed: ${modalClosed}`);
console.log(` ${modalClosed ? '✅ PASS' : '❌ FAIL'} - command="close" works`);
testResults.push({ test: 'command="close" closes dialog', passed: modalClosed });
} else {
console.log(' ⚠️ Close button with command="close" not found');
await page.keyboard.press('Escape');
testResults.push({ test: 'command="close" closes dialog', passed: false });
}
}
} else {
console.log(' ⚠️ Info button not found');
testResults.push({ test: 'command="show-modal" opens dialog', passed: false });
testResults.push({ test: 'command="close" closes dialog', passed: false });
}
// ========================================================================
// TEST 4: Contact button opens contact-modal
// ========================================================================
console.log('\n4️⃣ Testing contact button opens modal...');
const contactButton = await page.$('#contact-button');
if (contactButton) {
await contactButton.click();
await page.waitForTimeout(500);
const contactModalOpen = await page.evaluate(() => {
const modal = document.getElementById('contact-modal');
return modal && modal.hasAttribute('open');
});
console.log(` Contact modal opened: ${contactModalOpen}`);
console.log(` ${contactModalOpen ? '✅ PASS' : '❌ FAIL'} - Contact modal opens`);
testResults.push({ test: 'Contact modal opens via command', passed: contactModalOpen });
// Close with ESC
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} else {
console.log(' ⚠️ Contact button not found');
testResults.push({ test: 'Contact modal opens via command', passed: false });
}
// ========================================================================
// TEST 5: Shortcuts button opens shortcuts-modal
// ========================================================================
console.log('\n5️⃣ Testing shortcuts button opens modal...');
const shortcutsButton = await page.$('#shortcuts-button');
if (shortcutsButton) {
await shortcutsButton.click();
await page.waitForTimeout(500);
const shortcutsModalOpen = await page.evaluate(() => {
const modal = document.getElementById('shortcuts-modal');
return modal && modal.hasAttribute('open');
});
console.log(` Shortcuts modal opened: ${shortcutsModalOpen}`);
console.log(` ${shortcutsModalOpen ? '✅ PASS' : '❌ FAIL'} - Shortcuts modal opens`);
testResults.push({ test: 'Shortcuts modal opens via command', passed: shortcutsModalOpen });
// Close with ESC
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} else {
console.log(' ⚠️ Shortcuts button not found');
testResults.push({ test: 'Shortcuts modal opens via command', passed: false });
}
// ========================================================================
// TEST 6: PDF button in action bar opens pdf-modal
// ========================================================================
console.log('\n6️⃣ Testing PDF action button opens modal...');
const pdfActionButton = await page.$('#action-bar-pdf-btn');
if (pdfActionButton) {
const pdfAttrs = await page.$eval('#action-bar-pdf-btn', el => ({
commandfor: el.getAttribute('commandfor'),
command: el.getAttribute('command')
}));
console.log(` PDF button: commandfor="${pdfAttrs.commandfor}" command="${pdfAttrs.command}"`);
await pdfActionButton.click();
await page.waitForTimeout(500);
const pdfModalOpen = await page.evaluate(() => {
const modal = document.getElementById('pdf-modal');
return modal && modal.hasAttribute('open');
});
console.log(` PDF modal opened: ${pdfModalOpen}`);
console.log(` ${pdfModalOpen ? '✅ PASS' : '❌ FAIL'} - PDF modal opens via command`);
testResults.push({ test: 'PDF modal opens via command', passed: pdfModalOpen });
// Close with ESC
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} else {
console.log(' ⚠️ PDF action button not found');
testResults.push({ test: 'PDF modal opens via command', passed: false });
}
// ========================================================================
// FINAL SUMMARY
// ========================================================================
console.log("\n" + "=".repeat(70));
console.log("📊 TEST SUMMARY\n");
const totalTests = testResults.length;
const passedTests = testResults.filter(r => r.passed).length;
const failedTests = totalTests - passedTests;
testResults.forEach(result => {
console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`);
});
console.log(`\n Total: ${passedTests}/${totalTests} tests passed`);
console.log("=".repeat(70) + "\n");
if (failedTests === 0) {
console.log("🎉 ALL HTML INVOKER COMMANDS TESTS PASSED!");
} else {
console.log("⚠️ SOME TESTS FAILED - See details above");
console.log(" Note: command/commandfor requires Chrome 135+, Edge 135+, Firefox Nightly, Safari TP");
}
// Auto-close after tests if HEADLESS env is set
if (process.env.HEADLESS === 'true') {
await browser.close();
process.exit(failedTests === 0 ? 0 : 1);
} else {
console.log("\nBrowser will stay open for manual inspection.");
console.log("Press Ctrl+C when done.\n");
await new Promise(() => {}); // Keep browser open
}
}
await testInvokerCommands();
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env bun
/**
* CMD+K LAZY LOADING TEST
* =======================
* Tests that ninja-keys is lazy-loaded only when needed:
* - No ninja-keys element on initial page load
* - No esm.sh/ninja-keys loaded initially
* - CMD+K triggers dynamic import
* - ninja-keys element created and opened
* - Subsequent uses don't reload
*
* This optimization reduces initial page load by ~15 module requests
*/
import { chromium } from 'playwright';
const URL = "http://localhost:1999";
async function testCmdKLazyLoading() {
console.log('🚀 CMD+K LAZY LOADING TEST\n');
console.log('='.repeat(70));
const browser = await chromium.launch({ headless: process.env.HEADLESS === 'true' });
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await context.newPage();
const testResults = [];
const networkRequests = [];
// Monitor network requests
page.on('request', request => {
const url = request.url();
if (url.includes('ninja-keys') || url.includes('esm.sh')) {
networkRequests.push({
url,
timestamp: Date.now(),
type: request.resourceType()
});
}
});
await page.goto(URL);
await page.waitForTimeout(2000);
const initialRequestCount = networkRequests.length;
console.log(` Initial ninja-keys/esm.sh requests: ${initialRequestCount}`);
// ========================================================================
// TEST 1: No ninja-keys element on initial load
// ========================================================================
console.log('\n1️⃣ Testing no ninja-keys on initial load...');
const ninjaKeysOnLoad = await page.evaluate(() => {
const nk = document.getElementById('cmd-k-bar');
return {
exists: !!nk,
tagName: nk?.tagName || 'N/A'
};
});
console.log(` ninja-keys element exists: ${ninjaKeysOnLoad.exists}`);
const noInitialElement = !ninjaKeysOnLoad.exists;
console.log(` ${noInitialElement ? '✅ PASS' : '❌ FAIL'} - No ninja-keys element on initial load`);
testResults.push({ test: 'No ninja-keys element on initial load', passed: noInitialElement });
// ========================================================================
// TEST 2: No esm.sh requests on initial load
// ========================================================================
console.log('\n2️⃣ Testing no esm.sh requests on initial load...');
const noInitialRequests = initialRequestCount === 0;
console.log(` esm.sh requests before interaction: ${initialRequestCount}`);
console.log(` ${noInitialRequests ? '✅ PASS' : '❌ FAIL'} - No ninja-keys loaded initially`);
testResults.push({ test: 'No esm.sh requests on initial load', passed: noInitialRequests });
// ========================================================================
// TEST 3: Container element exists for lazy loading
// ========================================================================
console.log('\n3️⃣ Testing container element exists...');
const containerExists = await page.evaluate(() => {
const container = document.getElementById('cmd-k-container');
return !!container;
});
console.log(` cmd-k-container exists: ${containerExists}`);
console.log(` ${containerExists ? '✅ PASS' : '❌ FAIL'} - Container ready for lazy loading`);
testResults.push({ test: 'Container element exists', passed: containerExists });
// ========================================================================
// TEST 4: CMD+K triggers lazy load
// ========================================================================
console.log('\n4️⃣ Testing CMD+K triggers lazy load...');
const requestsBeforeCmdK = networkRequests.length;
// Press CMD+K (Mac) or Ctrl+K (Windows/Linux)
await page.keyboard.press('Meta+k');
await page.waitForTimeout(3000); // Wait for module to load and custom element to register
const requestsAfterCmdK = networkRequests.length;
const newRequests = requestsAfterCmdK - requestsBeforeCmdK;
console.log(` Requests before CMD+K: ${requestsBeforeCmdK}`);
console.log(` Requests after CMD+K: ${requestsAfterCmdK}`);
console.log(` New esm.sh requests: ${newRequests}`);
const lazyLoadTriggered = newRequests > 0;
console.log(` ${lazyLoadTriggered ? '✅ PASS' : '❌ FAIL'} - CMD+K triggers module load`);
testResults.push({ test: 'CMD+K triggers lazy load', passed: lazyLoadTriggered });
// ========================================================================
// TEST 5: ninja-keys element created after CMD+K
// ========================================================================
console.log('\n5️⃣ Testing ninja-keys element created...');
const ninjaKeysAfterCmdK = await page.evaluate(() => {
const nk = document.getElementById('cmd-k-bar');
if (!nk) return { exists: false, tagName: 'N/A', isOpen: false };
// ninja-keys uses shadow DOM with .modal.visible class
const shadow = nk.shadowRoot;
const modal = shadow?.querySelector('.modal');
return {
exists: true,
tagName: nk.tagName,
isOpen: modal?.classList?.contains('visible') || false
};
});
console.log(` ninja-keys exists: ${ninjaKeysAfterCmdK.exists}`);
console.log(` ninja-keys tag: ${ninjaKeysAfterCmdK.tagName}`);
console.log(` ninja-keys open: ${ninjaKeysAfterCmdK.isOpen}`);
const elementCreated = ninjaKeysAfterCmdK.exists && ninjaKeysAfterCmdK.tagName === 'NINJA-KEYS';
console.log(` ${elementCreated ? '✅ PASS' : '❌ FAIL'} - ninja-keys element created`);
testResults.push({ test: 'ninja-keys element created', passed: elementCreated });
// Close ninja-keys
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// ========================================================================
// TEST 6: Subsequent CMD+K doesn't reload module
// ========================================================================
console.log('\n6️⃣ Testing subsequent CMD+K doesn\'t reload...');
const requestsBeforeSecond = networkRequests.length;
// Press CMD+K again
await page.keyboard.press('Meta+k');
await page.waitForTimeout(500);
const requestsAfterSecond = networkRequests.length;
const additionalRequests = requestsAfterSecond - requestsBeforeSecond;
console.log(` Requests before 2nd CMD+K: ${requestsBeforeSecond}`);
console.log(` Requests after 2nd CMD+K: ${requestsAfterSecond}`);
console.log(` Additional requests: ${additionalRequests}`);
const noReload = additionalRequests === 0;
console.log(` ${noReload ? '✅ PASS' : '❌ FAIL'} - No module reload on subsequent use`);
testResults.push({ test: 'No module reload on subsequent use', passed: noReload });
// Close ninja-keys
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// ========================================================================
// TEST 7: Button click also triggers lazy load
// ========================================================================
console.log('\n7️⃣ Testing button click works with lazy-loaded ninja-keys...');
const cmdKButton = await page.$('#cmd-k-button');
if (cmdKButton) {
await cmdKButton.click();
await page.waitForTimeout(1000);
const openAfterClick = await page.evaluate(() => {
const nk = document.getElementById('cmd-k-bar');
if (!nk) return false;
// ninja-keys uses shadow DOM with .modal.visible class
const shadow = nk.shadowRoot;
const modal = shadow?.querySelector('.modal');
return modal?.classList?.contains('visible') || false;
});
console.log(` ninja-keys opened via button: ${openAfterClick}`);
console.log(` ${openAfterClick ? '✅ PASS' : '❌ FAIL'} - Button click opens ninja-keys`);
testResults.push({ test: 'Button click opens ninja-keys', passed: openAfterClick });
await page.keyboard.press('Escape');
} else {
console.log(' ⚠️ CMD+K button not found');
testResults.push({ test: 'Button click opens ninja-keys', passed: false });
}
// ========================================================================
// TEST 8: esm.sh used instead of unpkg (no redirect chains)
// ========================================================================
console.log('\n8️⃣ Testing esm.sh CDN used (no 302 redirects)...');
const esmShRequests = networkRequests.filter(r => r.url.includes('esm.sh'));
const unpkgRequests = networkRequests.filter(r => r.url.includes('unpkg.com/ninja-keys'));
console.log(` esm.sh requests: ${esmShRequests.length}`);
console.log(` unpkg ninja-keys requests: ${unpkgRequests.length}`);
const usesEsmSh = esmShRequests.length > 0 && unpkgRequests.length === 0;
console.log(` ${usesEsmSh ? '✅ PASS' : '❌ FAIL'} - Uses esm.sh CDN (pre-bundled)`);
testResults.push({ test: 'Uses esm.sh CDN (no redirects)', passed: usesEsmSh });
// ========================================================================
// FINAL SUMMARY
// ========================================================================
console.log("\n" + "=".repeat(70));
console.log("📊 TEST SUMMARY\n");
const totalTests = testResults.length;
const passedTests = testResults.filter(r => r.passed).length;
const failedTests = totalTests - passedTests;
testResults.forEach(result => {
console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`);
});
console.log(`\n Total: ${passedTests}/${totalTests} tests passed`);
// Show all network requests for debugging
if (networkRequests.length > 0) {
console.log('\n Network requests (ninja-keys/esm.sh):');
networkRequests.forEach((r, i) => {
console.log(` ${i + 1}. ${r.url.substring(0, 80)}...`);
});
}
console.log("=".repeat(70) + "\n");
if (failedTests === 0) {
console.log("🎉 ALL CMD+K LAZY LOADING TESTS PASSED!");
console.log(" Initial page load has NO ninja-keys overhead!");
} else {
console.log("⚠️ SOME TESTS FAILED - See details above");
}
// Auto-close after tests if HEADLESS env is set
if (process.env.HEADLESS === 'true') {
await browser.close();
process.exit(failedTests === 0 ? 0 : 1);
} else {
console.log("\nBrowser will stay open for manual inspection.");
console.log("Press Ctrl+C when done.\n");
await new Promise(() => {}); // Keep browser open
}
}
await testCmdKLazyLoading();