package handlers import ( "bytes" "log" "net/http" "path/filepath" "regexp" "strings" "text/template" ) // ============================================================================== // PLAIN TEXT HANDLER // Renders CV as clean plain text for terminal/AI consumption // ============================================================================== // PlainText renders the CV as plain text // Useful for: curl users, AI crawlers, accessibility, copy-paste func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) { // Get language from query parameter, default to English langCode := r.URL.Query().Get("lang") if langCode == "" { langCode = "en" } // Validate language if langCode != "en" && langCode != "es" { http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest) return } // Prepare template data using shared helper (loads CV data) data, err := h.prepareTemplateData(langCode) if err != nil { log.Printf("PlainText: Failed to load CV data: %v", err) http.Error(w, "Failed to load CV data", http.StatusInternalServerError) return } // Add base URL for footer data["BaseURL"] = h.serverAddr // Load and parse the plain text template tmplPath := filepath.Join("templates", "cv-text.txt") tmpl, err := template.New("cv-text.txt").ParseFiles(tmplPath) if err != nil { log.Printf("PlainText: Failed to load template: %v", err) http.Error(w, "Failed to load template", http.StatusInternalServerError) return } // Render to buffer var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { log.Printf("PlainText: Failed to execute template: %v", err) http.Error(w, "Failed to render template: "+err.Error(), http.StatusInternalServerError) return } // Clean up the output text := cleanPlainText(buf.String()) // Set response headers w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") // Write plain text response _, _ = w.Write([]byte(text)) } // cleanPlainText removes extra whitespace, HTML tags, and wraps long lines func cleanPlainText(text string) string { const maxLineLength = 80 // Remove HTML tags (from safeHTML fields) htmlTagRe := regexp.MustCompile(`<[^>]*>`) text = htmlTagRe.ReplaceAllString(text, "") // Replace multiple blank lines with double newline multipleNewlines := regexp.MustCompile(`\n{3,}`) text = multipleNewlines.ReplaceAllString(text, "\n\n") // Process each line: trim and wrap lines := strings.Split(text, "\n") var cleanedLines []string for _, line := range lines { line = strings.TrimRight(line, " \t") // Wrap long lines wrapped := wrapLine(line, maxLineLength) cleanedLines = append(cleanedLines, wrapped...) } text = strings.Join(cleanedLines, "\n") // Trim overall text = strings.TrimSpace(text) return text } // wrapLine wraps a single line to maxLength, preserving leading whitespace func wrapLine(line string, maxLength int) []string { if len(line) <= maxLength { return []string{line} } // Don't wrap separator lines (===, ---) if strings.HasPrefix(strings.TrimSpace(line), "===") || strings.HasPrefix(strings.TrimSpace(line), "---") { return []string{line} } // Detect leading whitespace for continuation lines indent := "" for _, r := range line { if r == ' ' || r == '\t' { indent += string(r) } else { break } } // For list items, add extra indent for continuation trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- ") { indent += " " } var wrapped []string remaining := line for len(remaining) > maxLength { // Find last space before maxLength breakPoint := maxLength for i := maxLength; i > 0; i-- { if remaining[i] == ' ' { breakPoint = i break } } // If no space found, force break at maxLength if breakPoint == maxLength && !strings.Contains(remaining[:maxLength], " ") { breakPoint = maxLength } wrapped = append(wrapped, remaining[:breakPoint]) remaining = indent + strings.TrimLeft(remaining[breakPoint:], " ") } if len(remaining) > 0 { wrapped = append(wrapped, remaining) } return wrapped }