9a848e8c53
Implement a command palette accessible via CMD+K/Ctrl+K using the ninja-keys web component. Features include: - New /api/cmd-k endpoint serving dynamic CV entries (experiences, projects, courses) - Language-aware responses with 1-hour cache headers - Scroll-to-section functionality for quick navigation - Enhanced keyboard shortcuts modal with CMD+K documentation - Comprehensive test coverage for API and UI interactions Also includes cleanup of deprecated debug test files and various UI polish improvements to contact form, themes, and action bar components.
273 lines
7.3 KiB
Go
273 lines
7.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
// 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("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
|
|
}
|
|
|
|
// Check icons parameter (default: true)
|
|
showIcons := true
|
|
if r.URL.Query().Get("icons") == "false" {
|
|
showIcons = 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("templates", "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("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// 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
|
|
}
|