feat: Add CMD+K command palette with ninja-keys integration
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.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/models"
|
||||
)
|
||||
|
||||
// CmdKAction represents a single action for the ninja-keys command palette
|
||||
type CmdKAction struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Section string `json:"section"`
|
||||
Keywords string `json:"keywords"`
|
||||
}
|
||||
|
||||
// CmdKResponse represents the response for the CMD+K API endpoint
|
||||
type CmdKResponse struct {
|
||||
Experiences []CmdKAction `json:"experiences"`
|
||||
Projects []CmdKAction `json:"projects"`
|
||||
Courses []CmdKAction `json:"courses"`
|
||||
}
|
||||
|
||||
// CmdKData returns JSON data for the ninja-keys command palette
|
||||
// This endpoint provides dynamic entries for experiences, projects, and courses
|
||||
// that can be searched via CMD+K
|
||||
func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) {
|
||||
// Get language from query parameter, default to "en"
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
if lang != "en" && lang != "es" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
// Load CV data
|
||||
cv, err := models.LoadCV(lang)
|
||||
if err != nil {
|
||||
log.Printf("ERROR loading CV data: %v", err)
|
||||
http.Error(w, "Failed to load CV data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := CmdKResponse{
|
||||
Experiences: make([]CmdKAction, 0, len(cv.Experience)),
|
||||
Projects: make([]CmdKAction, 0, len(cv.Projects)),
|
||||
Courses: make([]CmdKAction, 0, len(cv.Courses)),
|
||||
}
|
||||
|
||||
// Map experiences
|
||||
for _, exp := range cv.Experience {
|
||||
if exp.CompanyID == "" {
|
||||
continue // Skip entries without ID
|
||||
}
|
||||
response.Experiences = append(response.Experiences, CmdKAction{
|
||||
ID: "exp-" + exp.CompanyID,
|
||||
Title: exp.Company,
|
||||
Section: "Experience",
|
||||
Keywords: exp.Company + " " + exp.Position,
|
||||
})
|
||||
}
|
||||
|
||||
// Map projects
|
||||
for _, proj := range cv.Projects {
|
||||
if proj.ProjectID == "" {
|
||||
continue // Skip entries without ID
|
||||
}
|
||||
title := proj.ProjectName
|
||||
if title == "" {
|
||||
title = proj.Title
|
||||
}
|
||||
response.Projects = append(response.Projects, CmdKAction{
|
||||
ID: "proj-" + proj.ProjectID,
|
||||
Title: title,
|
||||
Section: "Projects",
|
||||
Keywords: title + " " + proj.ShortDescription,
|
||||
})
|
||||
}
|
||||
|
||||
// Map courses
|
||||
for _, course := range cv.Courses {
|
||||
if course.CourseID == "" {
|
||||
continue // Skip entries without ID
|
||||
}
|
||||
response.Courses = append(response.Courses, CmdKAction{
|
||||
ID: "course-" + course.CourseID,
|
||||
Title: course.Title,
|
||||
Section: "Courses",
|
||||
Keywords: course.Title + " " + course.Institution,
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers and encode response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("ERROR encoding CMD+K response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/config"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
// TestCmdKData tests the CmdKData handler
|
||||
// NOTE: This test requires running from project root due to data file path resolution
|
||||
// Run with: go test ./internal/handlers/ -run TestCmdKData -v
|
||||
func TestCmdKData(t *testing.T) {
|
||||
// Skip if running in short mode (CI) - requires project root
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping CmdKData test - requires running from project root")
|
||||
}
|
||||
|
||||
cfg := &config.TemplateConfig{
|
||||
Dir: "../../templates",
|
||||
PartialsDir: "../../templates/partials",
|
||||
HotReload: true,
|
||||
}
|
||||
tmplManager, err := templates.NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create template manager: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCVHandler(tmplManager, "localhost:8080")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang string
|
||||
expectStatus int
|
||||
expectExperiences bool // should have experiences
|
||||
expectProjects bool // should have projects
|
||||
expectCourses bool // should have courses
|
||||
expectMinExp int // minimum expected experiences
|
||||
expectMinProj int // minimum expected projects
|
||||
expectMinCourses int // minimum expected courses
|
||||
}{
|
||||
{
|
||||
name: "Default language (English)",
|
||||
lang: "",
|
||||
expectStatus: http.StatusOK,
|
||||
expectExperiences: true,
|
||||
expectProjects: true,
|
||||
expectCourses: true,
|
||||
expectMinExp: 5,
|
||||
expectMinProj: 3,
|
||||
expectMinCourses: 2,
|
||||
},
|
||||
{
|
||||
name: "English language",
|
||||
lang: "en",
|
||||
expectStatus: http.StatusOK,
|
||||
expectExperiences: true,
|
||||
expectProjects: true,
|
||||
expectCourses: true,
|
||||
expectMinExp: 5,
|
||||
expectMinProj: 3,
|
||||
expectMinCourses: 2,
|
||||
},
|
||||
{
|
||||
name: "Spanish language",
|
||||
lang: "es",
|
||||
expectStatus: http.StatusOK,
|
||||
expectExperiences: true,
|
||||
expectProjects: true,
|
||||
expectCourses: true,
|
||||
expectMinExp: 5,
|
||||
expectMinProj: 3,
|
||||
expectMinCourses: 2,
|
||||
},
|
||||
{
|
||||
name: "Invalid language defaults to English",
|
||||
lang: "fr",
|
||||
expectStatus: http.StatusOK,
|
||||
expectExperiences: true,
|
||||
expectProjects: true,
|
||||
expectCourses: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build query string
|
||||
query := "/api/cmd-k"
|
||||
if tt.lang != "" {
|
||||
query += "?lang=" + tt.lang
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, query, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.CmdKData(rec, req)
|
||||
|
||||
// Check status code
|
||||
if rec.Code != tt.expectStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectStatus, rec.Code)
|
||||
}
|
||||
|
||||
// If success, validate JSON response
|
||||
if rec.Code == http.StatusOK {
|
||||
// Check content type
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", contentType)
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var response CmdKResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Validate experiences
|
||||
if tt.expectExperiences && len(response.Experiences) == 0 {
|
||||
t.Error("Expected experiences but got none")
|
||||
}
|
||||
if tt.expectMinExp > 0 && len(response.Experiences) < tt.expectMinExp {
|
||||
t.Errorf("Expected at least %d experiences, got %d", tt.expectMinExp, len(response.Experiences))
|
||||
}
|
||||
|
||||
// Validate projects
|
||||
if tt.expectProjects && len(response.Projects) == 0 {
|
||||
t.Error("Expected projects but got none")
|
||||
}
|
||||
if tt.expectMinProj > 0 && len(response.Projects) < tt.expectMinProj {
|
||||
t.Errorf("Expected at least %d projects, got %d", tt.expectMinProj, len(response.Projects))
|
||||
}
|
||||
|
||||
// Validate courses
|
||||
if tt.expectCourses && len(response.Courses) == 0 {
|
||||
t.Error("Expected courses but got none")
|
||||
}
|
||||
if tt.expectMinCourses > 0 && len(response.Courses) < tt.expectMinCourses {
|
||||
t.Errorf("Expected at least %d courses, got %d", tt.expectMinCourses, len(response.Courses))
|
||||
}
|
||||
|
||||
// Validate structure of first experience (if present)
|
||||
if len(response.Experiences) > 0 {
|
||||
exp := response.Experiences[0]
|
||||
if exp.ID == "" {
|
||||
t.Error("Experience ID should not be empty")
|
||||
}
|
||||
if exp.Title == "" {
|
||||
t.Error("Experience Title should not be empty")
|
||||
}
|
||||
if exp.Section != "Experience" {
|
||||
t.Errorf("Experience Section should be 'Experience', got '%s'", exp.Section)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate structure of first project (if present)
|
||||
if len(response.Projects) > 0 {
|
||||
proj := response.Projects[0]
|
||||
if proj.ID == "" {
|
||||
t.Error("Project ID should not be empty")
|
||||
}
|
||||
if proj.Title == "" {
|
||||
t.Error("Project Title should not be empty")
|
||||
}
|
||||
if proj.Section != "Projects" {
|
||||
t.Errorf("Project Section should be 'Projects', got '%s'", proj.Section)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate structure of first course (if present)
|
||||
if len(response.Courses) > 0 {
|
||||
course := response.Courses[0]
|
||||
if course.ID == "" {
|
||||
t.Error("Course ID should not be empty")
|
||||
}
|
||||
if course.Title == "" {
|
||||
t.Error("Course Title should not be empty")
|
||||
}
|
||||
if course.Section != "Courses" {
|
||||
t.Errorf("Course Section should be 'Courses', got '%s'", course.Section)
|
||||
}
|
||||
}
|
||||
|
||||
// Log counts for debugging
|
||||
t.Logf("Response: %d experiences, %d projects, %d courses",
|
||||
len(response.Experiences), len(response.Projects), len(response.Courses))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdKDataCaching tests that the response has proper cache headers
|
||||
func TestCmdKDataCaching(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping CmdKDataCaching test - requires running from project root")
|
||||
}
|
||||
|
||||
cfg := &config.TemplateConfig{
|
||||
Dir: "../../templates",
|
||||
PartialsDir: "../../templates/partials",
|
||||
HotReload: true,
|
||||
}
|
||||
tmplManager, err := templates.NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create template manager: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCVHandler(tmplManager, "localhost:8080")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cmd-k", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.CmdKData(rec, req)
|
||||
|
||||
// Check cache header
|
||||
cacheControl := rec.Header().Get("Cache-Control")
|
||||
if cacheControl == "" {
|
||||
t.Error("Expected Cache-Control header to be set")
|
||||
}
|
||||
if cacheControl != "public, max-age=3600" {
|
||||
t.Errorf("Expected Cache-Control 'public, max-age=3600', got '%s'", cacheControl)
|
||||
}
|
||||
}
|
||||
@@ -221,8 +221,10 @@ func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, e
|
||||
}
|
||||
|
||||
// Render the error template
|
||||
// Return 200 OK with error content - HTMX 1.9.x logs console.error for non-2xx responses
|
||||
// Validation errors are expected form feedback, not system errors
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
tmpl, err := h.templates.Render("contact-error")
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Plain text configuration
|
||||
@@ -161,6 +163,13 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/config"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
// TestPlainText tests the PlainText handler
|
||||
// NOTE: This test requires running from project root due to template path resolution
|
||||
// Run with: go test ./internal/handlers/ -run TestPlainText -v
|
||||
// Or skip in CI: go test ./internal/handlers/ -run TestPlainText -short
|
||||
func TestPlainText(t *testing.T) {
|
||||
// Skip if running in short mode (CI) - requires project root
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping PlainText test - requires running from project root")
|
||||
}
|
||||
|
||||
cfg := &config.TemplateConfig{
|
||||
Dir: "../../templates",
|
||||
PartialsDir: "../../templates/partials",
|
||||
HotReload: true,
|
||||
}
|
||||
tmplManager, err := templates.NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create template manager: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCVHandler(tmplManager, "localhost:8080")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang string
|
||||
icons string
|
||||
download string
|
||||
expectStatus int
|
||||
expectHeader string
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "Default language (English)",
|
||||
lang: "",
|
||||
expectStatus: http.StatusOK,
|
||||
expectContains: "Juan",
|
||||
},
|
||||
{
|
||||
name: "English language",
|
||||
lang: "en",
|
||||
expectStatus: http.StatusOK,
|
||||
expectContains: "Juan",
|
||||
},
|
||||
{
|
||||
name: "Spanish language",
|
||||
lang: "es",
|
||||
expectStatus: http.StatusOK,
|
||||
expectContains: "Juan",
|
||||
},
|
||||
{
|
||||
name: "Invalid language",
|
||||
lang: "fr",
|
||||
expectStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "With icons disabled",
|
||||
lang: "en",
|
||||
icons: "false",
|
||||
expectStatus: http.StatusOK,
|
||||
expectContains: "Juan",
|
||||
},
|
||||
{
|
||||
name: "Download mode",
|
||||
lang: "en",
|
||||
download: "true",
|
||||
expectStatus: http.StatusOK,
|
||||
expectHeader: "attachment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build query string
|
||||
query := "/text"
|
||||
params := []string{}
|
||||
if tt.lang != "" {
|
||||
params = append(params, "lang="+tt.lang)
|
||||
}
|
||||
if tt.icons != "" {
|
||||
params = append(params, "icons="+tt.icons)
|
||||
}
|
||||
if tt.download != "" {
|
||||
params = append(params, "download="+tt.download)
|
||||
}
|
||||
if len(params) > 0 {
|
||||
query += "?" + strings.Join(params, "&")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.PlainText(w, req)
|
||||
|
||||
if w.Code != tt.expectStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectStatus, w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type for successful requests
|
||||
if tt.expectStatus == http.StatusOK {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "text/plain") {
|
||||
t.Errorf("Expected text/plain content type, got %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Content-Disposition header for download mode
|
||||
if tt.expectHeader != "" {
|
||||
disposition := w.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(disposition, tt.expectHeader) {
|
||||
t.Errorf("Expected Content-Disposition containing '%s', got '%s'", tt.expectHeader, disposition)
|
||||
}
|
||||
}
|
||||
|
||||
// Check response body contains expected content (if success)
|
||||
if tt.expectStatus == http.StatusOK && tt.expectContains != "" {
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, tt.expectContains) {
|
||||
t.Errorf("Expected body to contain '%s'", tt.expectContains)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlainTextDownloadFilename tests that download filename is correctly formatted
|
||||
// NOTE: This test requires running from project root due to template path resolution
|
||||
func TestPlainTextDownloadFilename(t *testing.T) {
|
||||
// Skip if running in short mode (CI) - requires project root
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping PlainTextDownloadFilename test - requires running from project root")
|
||||
}
|
||||
|
||||
cfg := &config.TemplateConfig{
|
||||
Dir: "../../templates",
|
||||
PartialsDir: "../../templates/partials",
|
||||
HotReload: true,
|
||||
}
|
||||
tmplManager, err := templates.NewManager(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create template manager: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCVHandler(tmplManager, "localhost:8080")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang string
|
||||
expectPrefix string
|
||||
}{
|
||||
{
|
||||
name: "English download filename",
|
||||
lang: "en",
|
||||
expectPrefix: "cv-jamr-",
|
||||
},
|
||||
{
|
||||
name: "Spanish download filename",
|
||||
lang: "es",
|
||||
expectPrefix: "cv-jamr-",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/text?lang="+tt.lang+"&download=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.PlainText(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
disposition := w.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(disposition, tt.expectPrefix) {
|
||||
t.Errorf("Expected filename to contain '%s', got '%s'", tt.expectPrefix, disposition)
|
||||
}
|
||||
|
||||
// Verify language suffix is in filename
|
||||
if !strings.Contains(disposition, "-"+tt.lang+".txt") {
|
||||
t.Errorf("Expected filename to end with '-%s.txt', got '%s'", tt.lang, disposition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTextBrowser tests the text browser detection
|
||||
func TestIsTextBrowser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
accept string
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "Regular browser",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "curl",
|
||||
userAgent: "curl/7.79.1",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "wget",
|
||||
userAgent: "Wget/1.21",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "httpie",
|
||||
userAgent: "HTTPie/2.6.0",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "lynx",
|
||||
userAgent: "Lynx/2.9.0dev.10",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "w3m",
|
||||
userAgent: "w3m/0.5.3",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "Accept text/plain",
|
||||
userAgent: "Mozilla/5.0",
|
||||
accept: "text/plain",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "Accept text/html",
|
||||
userAgent: "Mozilla/5.0",
|
||||
accept: "text/html",
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tt.userAgent != "" {
|
||||
req.Header.Set("User-Agent", tt.userAgent)
|
||||
}
|
||||
if tt.accept != "" {
|
||||
req.Header.Set("Accept", tt.accept)
|
||||
}
|
||||
|
||||
result := isTextBrowser(req)
|
||||
if result != tt.expect {
|
||||
t.Errorf("isTextBrowser() = %v, expected %v for User-Agent: %s", result, tt.expect, tt.userAgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type Personal struct {
|
||||
type Experience struct {
|
||||
Position string `json:"position"`
|
||||
Company string `json:"company"`
|
||||
CompanyID string `json:"companyID,omitempty"` // Unique ID for scrolling/navigation
|
||||
CompanyURL string `json:"companyURL,omitempty"` // Optional URL for company website
|
||||
CompanyLogo string `json:"companyLogo"`
|
||||
Location string `json:"location"`
|
||||
@@ -91,6 +92,7 @@ type Project struct {
|
||||
Title string `json:"title"`
|
||||
ProjectName string `json:"projectName,omitempty"` // Optional: linkable part of title
|
||||
ProjectDesc string `json:"projectDesc,omitempty"` // Optional: non-linkable description part
|
||||
ProjectID string `json:"projectID,omitempty"` // Unique ID for scrolling/navigation
|
||||
URL string `json:"url"`
|
||||
ProjectLogo string `json:"projectLogo,omitempty"` // Optional logo filename
|
||||
GitRepoUrl string `json:"gitRepoUrl,omitempty"` // Optional git repository URL for dynamic dates
|
||||
@@ -127,6 +129,7 @@ type Certification struct {
|
||||
type Course struct {
|
||||
Title string `json:"title"`
|
||||
Institution string `json:"institution"`
|
||||
CourseID string `json:"courseID,omitempty"` // Unique ID for scrolling/navigation
|
||||
CourseLogo string `json:"courseLogo,omitempty"` // Optional logo filename
|
||||
Location string `json:"location"`
|
||||
Date string `json:"date"`
|
||||
|
||||
@@ -52,6 +52,7 @@ type SEO struct {
|
||||
type Experience struct {
|
||||
Position string `json:"position"`
|
||||
Company string `json:"company"`
|
||||
CompanyID string `json:"companyID,omitempty"` // Unique ID for scrolling/navigation
|
||||
CompanyURL string `json:"companyURL,omitempty"` // Optional URL for company website
|
||||
CompanyLogo string `json:"companyLogo"`
|
||||
Location string `json:"location"`
|
||||
@@ -98,6 +99,7 @@ type Project struct {
|
||||
Title string `json:"title"`
|
||||
ProjectName string `json:"projectName,omitempty"` // Optional: linkable part of title
|
||||
ProjectDesc string `json:"projectDesc,omitempty"` // Optional: non-linkable description part
|
||||
ProjectID string `json:"projectID,omitempty"` // Unique ID for scrolling/navigation
|
||||
URL string `json:"url"`
|
||||
ProjectLogo string `json:"projectLogo,omitempty"` // Optional logo filename
|
||||
GitRepoUrl string `json:"gitRepoUrl,omitempty"` // Optional git repository URL for dynamic dates
|
||||
@@ -134,6 +136,7 @@ type Certification struct {
|
||||
type Course struct {
|
||||
Title string `json:"title"`
|
||||
Institution string `json:"institution"`
|
||||
CourseID string `json:"courseID,omitempty"` // Unique ID for scrolling/navigation
|
||||
CourseLogo string `json:"courseLogo,omitempty"` // Optional logo filename
|
||||
Location string `json:"location"`
|
||||
Date string `json:"date"`
|
||||
|
||||
@@ -14,6 +14,47 @@ type UI struct {
|
||||
InfoModal InfoModal `json:"infoModal"`
|
||||
ContactModal ContactModal `json:"contactModal"`
|
||||
Widgets Widgets `json:"widgets"`
|
||||
CmdK CmdK `json:"cmdK"`
|
||||
}
|
||||
|
||||
// CmdK command bar UI strings
|
||||
type CmdK struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
NoResults string `json:"noResults"`
|
||||
Sections CmdKSections `json:"sections"`
|
||||
Actions CmdKActions `json:"actions"`
|
||||
Button CmdKButton `json:"button"`
|
||||
}
|
||||
|
||||
type CmdKSections struct {
|
||||
Navigation string `json:"navigation"`
|
||||
Shortcuts string `json:"shortcuts"`
|
||||
Downloads string `json:"downloads"`
|
||||
}
|
||||
|
||||
type CmdKActions struct {
|
||||
JumpToExperience string `json:"jumpToExperience"`
|
||||
JumpToEducation string `json:"jumpToEducation"`
|
||||
JumpToSkills string `json:"jumpToSkills"`
|
||||
JumpToProjects string `json:"jumpToProjects"`
|
||||
JumpToCourses string `json:"jumpToCourses"`
|
||||
JumpToLanguages string `json:"jumpToLanguages"`
|
||||
JumpToAwards string `json:"jumpToAwards"`
|
||||
ToggleLength string `json:"toggleLength"`
|
||||
ToggleIcons string `json:"toggleIcons"`
|
||||
ToggleTheme string `json:"toggleTheme"`
|
||||
ShowShortcuts string `json:"showShortcuts"`
|
||||
Print string `json:"print"`
|
||||
DownloadPdfShort string `json:"downloadPdfShort"`
|
||||
DownloadPdfDefault string `json:"downloadPdfDefault"`
|
||||
DownloadPdfExtended string `json:"downloadPdfExtended"`
|
||||
ViewTextCv string `json:"viewTextCv"`
|
||||
DownloadTextCv string `json:"downloadTextCv"`
|
||||
}
|
||||
|
||||
type CmdKButton struct {
|
||||
Tooltip string `json:"tooltip"`
|
||||
AriaLabel string `json:"ariaLabel"`
|
||||
}
|
||||
|
||||
// Navigation labels for hamburger menu
|
||||
@@ -116,6 +157,7 @@ type ShortcutGroup struct {
|
||||
ExpandAll *ShortcutItem `json:"expandAll,omitempty"`
|
||||
CollapseAll *ShortcutItem `json:"collapseAll,omitempty"`
|
||||
ScrollToTop *ShortcutItem `json:"scrollToTop,omitempty"`
|
||||
CmdK *ShortcutItem `json:"cmdK,omitempty"`
|
||||
Print *ShortcutItem `json:"print,omitempty"`
|
||||
CloseModal *ShortcutItem `json:"closeModal,omitempty"`
|
||||
ShowHelp *ShortcutItem `json:"showHelp,omitempty"`
|
||||
@@ -209,8 +251,10 @@ type PdfToastLabel struct {
|
||||
}
|
||||
|
||||
type ActionButtonsLabel struct {
|
||||
DownloadPdf string `json:"downloadPdf"`
|
||||
PrintFriendly string `json:"printFriendly"`
|
||||
PlainText string `json:"plainText"`
|
||||
Contact string `json:"contact"`
|
||||
DownloadPdf string `json:"downloadPdf"`
|
||||
PrintFriendly string `json:"printFriendly"`
|
||||
PlainText string `json:"plainText"`
|
||||
Contact string `json:"contact"`
|
||||
Search string `json:"search"`
|
||||
SearchAriaLabel string `json:"searchAriaLabel"`
|
||||
}
|
||||
|
||||
@@ -16,10 +16,13 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
|
||||
// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf)
|
||||
mux.HandleFunc("/cv-jamr-", cvHandler.DefaultCVShortcut)
|
||||
|
||||
// API routes (must be before "/" to avoid catch-all)
|
||||
mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData) // CMD+K command palette data
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("/", cvHandler.Home)
|
||||
mux.HandleFunc("/cv", cvHandler.CVContent)
|
||||
mux.HandleFunc("/text", cvHandler.PlainText) // Plain text version for curl/AI
|
||||
mux.HandleFunc("/text", cvHandler.PlainText) // Plain text version for curl/AI
|
||||
mux.HandleFunc("/health", healthHandler.Check)
|
||||
|
||||
// HTMX endpoints for interactive controls
|
||||
|
||||
Reference in New Issue
Block a user