Files
cv-site/internal/handlers/cv_streaming.go
T
juanatsap 585949b709 chore: update Matomo analytics URL to matomo.txeo.club
Migrate from matomo.morenorub.io to matomo.txeo.club in both
CSP headers and tracking script. Also fix errcheck lint warnings
in cv_streaming.go.
2026-03-15 20:22:41 +00:00

249 lines
8.2 KiB
Go

package handlers
import (
"bytes"
"log"
"net/http"
"strings"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/middleware"
)
// ==============================================================================
// STREAMING HANDLERS
// HTML Streaming for progressive page rendering
// ==============================================================================
// HomeStreaming renders the CV page using HTTP streaming for faster perceived load
// Strategy: Stream the <head> and initial skeleton immediately, then stream body content
// Falls back to classic Home handler if streaming not supported
func (h *CVHandler) HomeStreaming(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
// Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf)
if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") {
h.DefaultCVShortcut(w, r)
return
}
// Detect text-based browsers and serve plain text version
if isTextBrowser(r) {
h.PlainText(w, r)
return
}
// Check if response writer supports flushing
flusher, ok := w.(http.Flusher)
if !ok {
// Streaming not supported - fallback to classic rendering
log.Printf("[streaming] Flusher not supported, falling back to classic handler")
h.Home(w, r)
return
}
// Validate language
lang, ok := httputil.LangOrError(r)
if !ok {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return
}
// Get user preferences from context
prefs := middleware.GetPreferences(r)
// ==================================================================
// CHUNK 1: Send minimal loading indicator immediately
// ==================================================================
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Cache-Control", "no-cache")
// Send loading indicator that will be replaced
loadingHTML := generateLoadingHTML(lang)
if _, err := w.Write([]byte(loadingHTML)); err != nil {
log.Printf("[streaming] Error writing loading HTML: %v", err)
return
}
flusher.Flush()
skeletonTime := time.Since(startTime)
log.Printf("[streaming] Loading indicator sent in %v", skeletonTime)
// ==================================================================
// CHUNK 2: Prepare data (parallel git operations happen here)
// ==================================================================
data, err := h.prepareTemplateData(lang)
if err != nil {
// Error after headers sent - send inline error
errorHTML := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Error loading CV</h1><p>Please refresh the page.</p></div>';</script>`
_, _ = w.Write([]byte(errorHTML))
flusher.Flush()
return
}
// Add preference-specific fields
cvLengthClass := "cv-short"
if prefs.CVLength == "long" {
cvLengthClass = "cv-long"
}
data["CVLengthClass"] = cvLengthClass
data["ShowIcons"] = (prefs.CVIcons == "show")
data["ThemeClean"] = (prefs.CVTheme == "clean")
dataTime := time.Since(startTime)
log.Printf("[streaming] Data prepared in %v (delta: %v)", dataTime, dataTime-skeletonTime)
// ==================================================================
// CHUNK 3: Render full template and stream it
// ==================================================================
tmpl, err := h.templates.Render("index.html")
if err != nil {
errorHTML := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Template Error</h1></div>';</script>`
_, _ = w.Write([]byte(errorHTML))
flusher.Flush()
return
}
var contentBuf bytes.Buffer
if err := tmpl.Execute(&contentBuf, data); err != nil {
errorHTML := `<script>document.body.innerHTML = '<div style="padding:2rem;text-align:center;"><h1>Render Error</h1></div>';</script>`
_, _ = w.Write([]byte(errorHTML))
flusher.Flush()
return
}
// Replace the entire document with the full rendered content
// This preserves all body attributes including hyperscript handlers
fullHTML := contentBuf.String()
// Escape the HTML for JavaScript string
escapedHTML := escapeForJS(fullHTML)
// Script inside body that replaces entire document
// We need to use a DOMParser approach since document.write() doesn't work reliably
// after the initial document has been parsed
replaceScript := `<script>
(function() {
var newHTML = ` + escapedHTML + `;
// Parse the new HTML
var parser = new DOMParser();
var newDoc = parser.parseFromString(newHTML, 'text/html');
// Replace the entire document
document.documentElement.innerHTML = newDoc.documentElement.innerHTML;
// Copy attributes from new html tag (like lang)
Array.from(newDoc.documentElement.attributes).forEach(function(attr) {
document.documentElement.setAttribute(attr.name, attr.value);
});
// Copy attributes from new body tag (like class, hyperscript _)
var newBody = newDoc.body;
var currentBody = document.body;
Array.from(newBody.attributes).forEach(function(attr) {
currentBody.setAttribute(attr.name, attr.value);
});
// Re-execute scripts (they don't run when inserted via innerHTML)
document.querySelectorAll('script').forEach(function(oldScript) {
if (oldScript.src) {
// External scripts - reload them
var newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(function(attr) {
newScript.setAttribute(attr.name, attr.value);
});
oldScript.parentNode.replaceChild(newScript, oldScript);
}
});
// Trigger DOMContentLoaded for any listeners
document.dispatchEvent(new Event('DOMContentLoaded'));
})();
</script>
</body>
</html>`
_, _ = w.Write([]byte(replaceScript))
flusher.Flush()
totalTime := time.Since(startTime)
log.Printf("[streaming] Complete in %v (loading: %v, data: %v)", totalTime, skeletonTime, dataTime-skeletonTime)
}
// generateLoadingHTML creates a minimal loading page that shows instantly
func generateLoadingHTML(lang string) string {
langAttr := "en"
title := "Loading CV..."
if lang == "es" {
langAttr = "es"
title = "Cargando CV..."
}
// Note: We intentionally leave body and html tags OPEN
// The streaming handler will append the replace script and close them
return `<!DOCTYPE html>
<html lang="` + langAttr + `">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>` + title + `</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.streaming-loader {
text-align: center;
}
.streaming-spinner {
width: 50px;
height: 50px;
border: 4px solid #e0e0e0;
border-top-color: #2ecc71;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.streaming-loader-text {
color: #666;
font-size: 1.1rem;
}
@media (prefers-color-scheme: dark) {
body { background: #1a1a1a; }
.streaming-spinner { border-color: #333; border-top-color: #2ecc71; }
.streaming-loader-text { color: #aaa; }
}
</style>
</head>
<body>
<div id="streaming-loader" class="streaming-loader">
<div class="streaming-spinner"></div>
<p class="streaming-loader-text">` + title + `</p>
</div>
`
}
// escapeForJS escapes a string to be safely embedded in JavaScript
func escapeForJS(s string) string {
// Use backtick template literal with proper escaping
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "`", "\\`")
s = strings.ReplaceAll(s, "${", "\\${")
return "`" + s + "`"
}