585949b709
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.
249 lines
8.2 KiB
Go
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 + "`"
|
|
}
|