Files
cv-site/internal/handlers/cv_text.go
T

192 lines
4.8 KiB
Go
Raw Normal View History

package handlers
import (
"bytes"
"log"
"net/http"
"path/filepath"
"regexp"
"strings"
"text/template"
)
// Text-based browsers and CLI tools that should get plain text
var textBrowsers = []string{
"curl",
"wget",
"httpie",
"lynx",
"w3m",
"links",
"elinks",
"browsh",
"carbonyl",
"netrik",
"retawq",
"surfraw",
}
// isTextBrowser detects if the request comes from a text-based browser or CLI tool
func isTextBrowser(r *http.Request) bool {
ua := strings.ToLower(r.Header.Get("User-Agent"))
// Check for known text browsers
for _, browser := range textBrowsers {
if strings.Contains(ua, browser) {
return true
}
}
// Check Accept header - if client prefers text/plain
accept := r.Header.Get("Accept")
if strings.HasPrefix(accept, "text/plain") {
return true
}
return false
}
// ==============================================================================
// 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
}