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:
@@ -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
|
||||
@@ -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
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user