08b39653ba
- Add X-Robots-Tag: noindex, nofollow header to /text endpoint - Add Link: canonical header pointing to HTML version - Add Disallow: /text to robots.txt - Update sitemap.xml lastmod to 2026-04-09
265 lines
7.3 KiB
Go
265 lines
7.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
c "github.com/juanatsap/cv-site/internal/constants"
|
|
"github.com/juanatsap/cv-site/internal/httputil"
|
|
)
|
|
|
|
// Plain text configuration
|
|
const plainTextLineWidth = 80
|
|
|
|
// 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(c.HeaderUserAgent))
|
|
|
|
// 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(c.HeaderAccept)
|
|
return strings.HasPrefix(accept, c.ContentTypePlainSimple)
|
|
}
|
|
|
|
// ==============================================================================
|
|
// 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) {
|
|
langCode, ok := httputil.LangOrError(r)
|
|
if !ok {
|
|
http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check icons parameter (default: true)
|
|
showIcons := r.URL.Query().Get("icons") != "false"
|
|
|
|
// 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, icons setting, and line width
|
|
data["BaseURL"] = h.serverAddr
|
|
data["Icons"] = showIcons
|
|
data["LineWidth"] = plainTextLineWidth
|
|
|
|
// Template functions for text formatting
|
|
funcMap := template.FuncMap{
|
|
// center centers text within the line width
|
|
"center": func(text string) string {
|
|
runes := []rune(text)
|
|
textLen := len(runes)
|
|
if textLen >= plainTextLineWidth {
|
|
return text
|
|
}
|
|
padding := (plainTextLineWidth - textLen) / 2
|
|
return strings.Repeat(" ", padding) + text
|
|
},
|
|
// separator creates a centered separator line
|
|
"separator": func(char string, length int) string {
|
|
line := strings.Repeat(char, length)
|
|
padding := (plainTextLineWidth - length) / 2
|
|
return strings.Repeat(" ", padding) + line
|
|
},
|
|
// box creates a centered box frame with rounded corners around text
|
|
"box": func(text string) string {
|
|
// Calculate visual width (emojis display as 2 chars wide)
|
|
visualWidth := 0
|
|
for _, r := range text {
|
|
if r > 0x1F000 { // Emoji range
|
|
visualWidth += 2
|
|
} else {
|
|
visualWidth++
|
|
}
|
|
}
|
|
|
|
innerPadding := 3 // spaces on each side inside the box
|
|
boxWidth := visualWidth + (innerPadding * 2) + 2 // padding both sides + 2 border chars
|
|
topBottom := "╭" + strings.Repeat("─", boxWidth-2) + "╮"
|
|
middle := "│" + strings.Repeat(" ", innerPadding) + text + strings.Repeat(" ", innerPadding) + "│"
|
|
bottom := "╰" + strings.Repeat("─", boxWidth-2) + "╯"
|
|
|
|
// Center each line (using visual width for proper alignment)
|
|
centerLine := func(line string, lineVisualWidth int) string {
|
|
if lineVisualWidth >= plainTextLineWidth {
|
|
return line
|
|
}
|
|
padding := (plainTextLineWidth - lineVisualWidth) / 2
|
|
return strings.Repeat(" ", padding) + line
|
|
}
|
|
|
|
return centerLine(topBottom, boxWidth) + "\n" + centerLine(middle, boxWidth) + "\n" + centerLine(bottom, boxWidth)
|
|
},
|
|
}
|
|
|
|
// Load and parse the plain text template with custom functions
|
|
tmplPath := filepath.Join(c.DirTemplates, "cv-text.txt")
|
|
tmpl, err := template.New("cv-text.txt").Funcs(funcMap).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(c.HeaderContentType, c.ContentTypePlainText)
|
|
w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff)
|
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
|
w.Header().Set("Link", `<https://juan.andres.morenorub.io/?lang=`+langCode+`>; rel="canonical"`)
|
|
|
|
// Check if download is requested
|
|
if r.URL.Query().Get("download") == "true" {
|
|
year := time.Now().Year()
|
|
filename := fmt.Sprintf("cv-jamr-%d-%s.txt", year, langCode)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
}
|
|
|
|
// 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
|
|
// Uses runes for proper Unicode/emoji handling
|
|
func wrapLine(line string, maxLength int) []string {
|
|
runes := []rune(line)
|
|
|
|
if len(runes) <= 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 runes {
|
|
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 := runes
|
|
|
|
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 {
|
|
hasSpace := false
|
|
for i := 0; i < maxLength; i++ {
|
|
if remaining[i] == ' ' {
|
|
hasSpace = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSpace {
|
|
breakPoint = maxLength
|
|
}
|
|
}
|
|
|
|
wrapped = append(wrapped, string(remaining[:breakPoint]))
|
|
remaining = []rune(indent + strings.TrimLeft(string(remaining[breakPoint:]), " "))
|
|
}
|
|
|
|
if len(remaining) > 0 {
|
|
wrapped = append(wrapped, string(remaining))
|
|
}
|
|
|
|
return wrapped
|
|
}
|