refactor: Split monolithic handler into focused files

Split internal/handlers/cv.go (1,001 lines) into 5 focused files:

Structure:
- cv.go (29 lines) - CVHandler struct + constructor
- cv_pages.go (290 lines) - Page handlers (Home, CVContent, DefaultCVShortcut)
- cv_pdf.go (153 lines) - PDF export handler (ExportPDF)
- cv_htmx.go (218 lines) - HTMX toggle handlers (Length, Icons, Language, Theme)
- cv_helpers.go (385 lines) - Helper functions (skills, dates, git, templates, cookies)

Benefits:
- Single Responsibility: Each file has one clear purpose
- Improved Discoverability: Easy to find specific functionality
- Reduced Cognitive Load: 200-400 lines per file vs 1,001
- Parallel Development: No conflicts when editing different concerns
- Better Organization: Clear section markers and grouping
- Maintainability: Trade +74 lines (+7.4%) for better organization

Testing:
- All Go tests pass (fileutil, handlers, lang, cv, ui)
- Server builds and runs correctly
- All HTTP endpoints functional
- No breaking changes

Documentation:
- Create _go-learning/refactorings/003-handler-split.md
- Document architecture, benefits, and trade-offs
- Explain WHY single package vs separate packages
This commit is contained in:
juanatsap
2025-11-20 17:01:50 +00:00
parent 29a00f432b
commit 4acde64c01
6 changed files with 1424 additions and 977 deletions
+153
View File
@@ -0,0 +1,153 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strings"
"time"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
"github.com/juanatsap/cv-site/internal/pdf"
)
// ==============================================================================
// PDF EXPORT HANDLER
// Handles PDF generation with customizable options (length, icons, version, theme)
// ==============================================================================
// ExportPDF handles PDF export requests using chromedp
func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
// Extract and validate query parameters
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return
}
length := r.URL.Query().Get("length")
if length == "" {
length = "short"
}
if length != "short" && length != "long" {
HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'"))
return
}
icons := r.URL.Query().Get("icons")
if icons == "" {
icons = "show"
}
if icons != "show" && icons != "hide" {
HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'"))
return
}
version := r.URL.Query().Get("version")
if version == "" {
version = "with_skills"
}
if version != "with_skills" && version != "clean" {
HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'"))
return
}
log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version)
// Load CV data to get name for filename
cv, err := cvmodel.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Prepare cookies to set preferences
cookies := map[string]string{
"cv-length": length,
"cv-icons": icons,
"cv-language": lang,
}
// Set theme cookie based on version parameter
if version == "clean" {
cookies["cv-theme"] = "clean"
} else {
cookies["cv-theme"] = "default"
}
// CRITICAL: ALWAYS force light mode for PDF generation (print-friendly)
// This ensures PDFs are NEVER generated in dark mode, regardless of user's preference
cookies["color-theme"] = "light"
// Construct URL for PDF generation (navigate to home page)
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
// Determine render mode based on version parameter
// Clean version: use @media print CSS (print-friendly, no sidebars)
// Extended version: use @media screen CSS (full layout with sidebars)
var renderMode pdf.RenderMode
if version == "clean" {
renderMode = pdf.RenderModePrint
} else {
renderMode = pdf.RenderModeScreen
}
// Generate PDF with cookies and appropriate render mode
ctx := r.Context()
pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode)
if err != nil {
log.Printf("PDF generation failed: %v", err)
HandleError(w, r, InternalError(err))
return
}
// Generate filename based on parameters
// Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
// Note: {version} is OMITTED when it's "clean"
// Examples:
// - cv-short-jamr-2025-es.pdf (clean version, no skills)
// - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar)
// - cv-long-jamr-2025-en.pdf (clean version, no skills)
// - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar)
// Generate initials from name
nameParts := strings.Fields(cv.Personal.Name)
initials := ""
for _, part := range nameParts {
if len(part) > 0 {
// Take first letter of each name part
initials += string([]rune(part)[0])
}
}
initials = strings.ToLower(initials)
// Get current year
currentYear := time.Now().Year()
// Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
// Omit version if it's "clean"
// Replace underscores with hyphens in version for filename (with_skills → with-skills)
var filename string
if version == "clean" {
filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang)
} else {
versionForFilename := strings.ReplaceAll(version, "_", "-")
filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang)
}
// Set response headers
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData)))
// Write PDF data
if _, err := w.Write(pdfData); err != nil {
log.Printf("Error writing PDF response: %v", err)
return
}
log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData))
}