From 2d3d3de8cda2011223867ca0ae0a92c7246cd3e6 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Tue, 2 Dec 2025 08:29:54 +0000 Subject: [PATCH] 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 --- doc/20-HTMX-LEARNING.md | 502 ++++++++++++++++++ internal/middleware/security.go | 2 +- templates/index.html | 392 +------------- templates/partials/layout/body-scripts.html | 93 ++++ templates/partials/layout/head-fonts.html | 7 + .../partials/layout/head-fouc-prevention.html | 40 ++ templates/partials/layout/head-scripts.html | 34 ++ .../partials/layout/head-structured-data.html | 211 ++++++++ templates/partials/layout/head-styles.html | 10 + templates/partials/layout/head.html | 47 ++ templates/partials/modals/contact-modal.html | 2 +- templates/partials/modals/info-modal.html | 2 +- templates/partials/modals/pdf-modal.html | 3 +- .../partials/modals/shortcuts-modal.html | 2 +- .../partials/navigation/action-buttons.html | 8 +- .../partials/navigation/hamburger-menu.html | 6 +- templates/partials/widgets/cmd-k-button.html | 6 +- .../partials/widgets/contact-button.html | 4 +- templates/partials/widgets/info-button.html | 4 +- .../partials/widgets/shortcuts-button.html | 4 +- tests/mjs/75-html-invoker-commands.test.mjs | 240 +++++++++ tests/mjs/76-cmd-k-lazy-loading.test.mjs | 256 +++++++++ 22 files changed, 1489 insertions(+), 386 deletions(-) create mode 100644 doc/20-HTMX-LEARNING.md create mode 100644 templates/partials/layout/body-scripts.html create mode 100644 templates/partials/layout/head-fonts.html create mode 100644 templates/partials/layout/head-fouc-prevention.html create mode 100644 templates/partials/layout/head-scripts.html create mode 100644 templates/partials/layout/head-structured-data.html create mode 100644 templates/partials/layout/head-styles.html create mode 100644 templates/partials/layout/head.html create mode 100644 tests/mjs/75-html-invoker-commands.test.mjs create mode 100644 tests/mjs/76-cmd-k-lazy-loading.test.mjs diff --git a/doc/20-HTMX-LEARNING.md b/doc/20-HTMX-LEARNING.md new file mode 100644 index 0000000..b4bbeed --- /dev/null +++ b/doc/20-HTMX-LEARNING.md @@ -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 + + + +
+ +
+``` + +--- + +## 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 + + +
Main content here
+ + + + +
+ 5 new messages +
+``` + +### 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 + + + +
+ + +
+ + +
+ + {{template "title-badges" .}} + +
+ + + + +
+ {{template "section-header" .}} + {{template "section-education" .}} + {{template "section-experience" .}} +
+
+
+ + +
+ + {{template "title-badges" .}} + +
+ +
+ {{template "section-awards" .}} + {{template "section-projects" .}} + {{template "section-courses" .}} +
+ + + +
+ + {{template "cv-footer" .}} +
+``` + +### 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 + + +``` + +### 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 +
+ + + + + +
+``` + +### Server Responses + +**Success**: Returns success partial +```html + +
+ +

Message sent successfully!

+
+``` + +**Error**: Returns error partial +```html + +
+ +

{{.ErrorMessage}}

+
+``` + +### 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 +
+ +
+

John Smith

+

Software Engineer

+
+ + +
+
+
+
+
+``` + +### 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 + +hx-trigger="input changed delay:500ms" + + +hx-trigger="revealed" + + +hx-trigger="load" + + +hx-trigger="intersect" +``` + +--- + +## Best Practices Learned + +### 1. Use OOB for Multi-Element Updates + +Instead of multiple requests, use OOB swaps: + +```html + +
Updated main
+ +
New notification
+``` + +### 2. Use `hx-swap="none"` for Side Effects + +When you only need server-side effects (cookies, database), skip the swap: + +```html + +``` + +```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 diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 7625451..d9f32e3 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -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:; " + diff --git a/templates/index.html b/templates/index.html index 20b413d..b4bf6ad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,342 +1,6 @@ - - - - - - {{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{if .IsProduction}} - - {{else}} - - {{end}} - - - - - - - - - - - - - - - - - - - - - - - - {{range .CV.Education}} - - {{end}} - - - {{range .CV.Courses}} - - {{end}} - +{{template "head" .}} - -
+ + + +
{{template "action-bar" .}} {{template "hamburger-menu" .}} {{template "color-theme-switcher" .}} - + + +
-
{{template "cv-content.html" .}}
-
+ + + + {{template "page-footer" .}} - {{template "error-toast" .}} {{template "pdf-toast" .}} - + + +
- {{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" .}} + + + + {{template "info-modal" .}} {{template "shortcuts-modal" .}} {{template "pdf-modal" .}} {{template "contact-modal" .}} {{template "zoom-control" .}} - - - - - - - - - - + + + +{{template "body-scripts" .}} diff --git a/templates/partials/layout/body-scripts.html b/templates/partials/layout/body-scripts.html new file mode 100644 index 0000000..774bf43 --- /dev/null +++ b/templates/partials/layout/body-scripts.html @@ -0,0 +1,93 @@ +{{define "body-scripts"}} + +
+ + + + + + + + + + +{{end}} diff --git a/templates/partials/layout/head-fonts.html b/templates/partials/layout/head-fonts.html new file mode 100644 index 0000000..f19331e --- /dev/null +++ b/templates/partials/layout/head-fonts.html @@ -0,0 +1,7 @@ +{{define "head-fonts"}} + + + + + +{{end}} diff --git a/templates/partials/layout/head-fouc-prevention.html b/templates/partials/layout/head-fouc-prevention.html new file mode 100644 index 0000000..36531ba --- /dev/null +++ b/templates/partials/layout/head-fouc-prevention.html @@ -0,0 +1,40 @@ +{{define "head-fouc-prevention"}} + + + + +{{end}} diff --git a/templates/partials/layout/head-scripts.html b/templates/partials/layout/head-scripts.html new file mode 100644 index 0000000..55edb5e --- /dev/null +++ b/templates/partials/layout/head-scripts.html @@ -0,0 +1,34 @@ +{{define "head-scripts"}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{end}} diff --git a/templates/partials/layout/head-structured-data.html b/templates/partials/layout/head-structured-data.html new file mode 100644 index 0000000..ca99618 --- /dev/null +++ b/templates/partials/layout/head-structured-data.html @@ -0,0 +1,211 @@ +{{define "head-structured-data"}} + + + + + + + + + + + + + + + {{range .CV.Education}} + + {{end}} + + + {{range .CV.Courses}} + + {{end}} +{{end}} diff --git a/templates/partials/layout/head-styles.html b/templates/partials/layout/head-styles.html new file mode 100644 index 0000000..a5b568e --- /dev/null +++ b/templates/partials/layout/head-styles.html @@ -0,0 +1,10 @@ +{{define "head-styles"}} + + {{if .IsProduction}} + + {{else}} + + {{end}} + + +{{end}} diff --git a/templates/partials/layout/head.html b/templates/partials/layout/head.html new file mode 100644 index 0000000..8170518 --- /dev/null +++ b/templates/partials/layout/head.html @@ -0,0 +1,47 @@ +{{define "head"}} + + + + + + {{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{template "head-fouc-prevention" .}} +{{template "head-scripts" .}} +{{template "head-styles" .}} +{{template "head-fonts" .}} +{{template "head-structured-data" .}} + +{{end}} diff --git a/templates/partials/modals/contact-modal.html b/templates/partials/modals/contact-modal.html index 5b2f743..46c2726 100644 --- a/templates/partials/modals/contact-modal.html +++ b/templates/partials/modals/contact-modal.html @@ -17,7 +17,7 @@ if responseDiv set responseDiv.innerHTML to '' end">
- diff --git a/templates/partials/modals/info-modal.html b/templates/partials/modals/info-modal.html index 8ff57fd..9111e61 100644 --- a/templates/partials/modals/info-modal.html +++ b/templates/partials/modals/info-modal.html @@ -3,7 +3,7 @@
- diff --git a/templates/partials/modals/pdf-modal.html b/templates/partials/modals/pdf-modal.html index 9172127..0b6984c 100644 --- a/templates/partials/modals/pdf-modal.html +++ b/templates/partials/modals/pdf-modal.html @@ -19,7 +19,8 @@ diff --git a/templates/partials/modals/shortcuts-modal.html b/templates/partials/modals/shortcuts-modal.html index 01e9e9d..2caffdd 100644 --- a/templates/partials/modals/shortcuts-modal.html +++ b/templates/partials/modals/shortcuts-modal.html @@ -3,7 +3,7 @@
- diff --git a/templates/partials/navigation/action-buttons.html b/templates/partials/navigation/action-buttons.html index bb0857b..cd91816 100644 --- a/templates/partials/navigation/action-buttons.html +++ b/templates/partials/navigation/action-buttons.html @@ -4,7 +4,8 @@ diff --git a/templates/partials/navigation/hamburger-menu.html b/templates/partials/navigation/hamburger-menu.html index c5ac521..9620131 100644 --- a/templates/partials/navigation/hamburger-menu.html +++ b/templates/partials/navigation/hamburger-menu.html @@ -177,7 +177,8 @@
diff --git a/templates/partials/widgets/cmd-k-button.html b/templates/partials/widgets/cmd-k-button.html index cfe9649..238f0b4 100644 --- a/templates/partials/widgets/cmd-k-button.html +++ b/templates/partials/widgets/cmd-k-button.html @@ -1,11 +1,11 @@ {{define "cmd-k-button"}} + {{end}} diff --git a/templates/partials/widgets/contact-button.html b/templates/partials/widgets/contact-button.html index edc6821..08c4d5a 100644 --- a/templates/partials/widgets/contact-button.html +++ b/templates/partials/widgets/contact-button.html @@ -1,9 +1,11 @@ {{define "contact-button"}} + {{end}} diff --git a/templates/partials/widgets/shortcuts-button.html b/templates/partials/widgets/shortcuts-button.html index 73b3fd8..e487ffa 100644 --- a/templates/partials/widgets/shortcuts-button.html +++ b/templates/partials/widgets/shortcuts-button.html @@ -1,9 +1,11 @@ {{define "shortcuts-button"}} +