<!-- markdownlint-disable MD013 -->

# Implementation Plan: Docs Search API

## Overview
Add backend full-text search for 152 documentation files using a new `/api/docs/search` endpoint, replacing the planned build-time Lunr approach with runtime server-side search.

**Type**: Full-stack task (backend API + frontend changes)  
**Status**: Planned  
**Priority**: Medium  
**Estimated effort**: 3-4 hours

---

## Table of Contents
1. [Architecture Decision](#architecture-decision)
2. [Backend Implementation](#backend-implementation)
3. [Frontend Changes](#frontend-changes)
4. [Testing Strategy](#testing-strategy)
5. [Dependencies to Remove](#dependencies-to-remove)
6. [Documentation Updates](#documentation-updates)
7. [Implementation Checklist](#implementation-checklist)

---

## Architecture Decision

### Why Backend API Instead of Build-time Lunr?

| Aspect | Build-time Lunr (Original Plan) | Backend API (This Plan) |
|--------|----------------------------------|-------------------------|
| Build step | ✅ Required | ❌ None |
| Network request | ❌ Client-side only | ✅ API call |
| Server load | ❌ None | ✅ Minimal (152 docs) |
| Offline support | ✅ Yes | ❌ No |
| Search speed | ⚡ Instant | 🌐 Network latency |
| Bundle size | +50KB | No change |
| Always current | ❌ Need rebuild | ✅ Yes |
| Consistency | New pattern | Matches existing `/api/media-items/search` |

**Decision**: Backend API is simpler and more consistent with existing architecture.

---

## Backend Implementation

### 1. Add Search Method to DocsHandler

**File**: `internal/docs/search.go` (new file)

```go
package docs

import (
	"bufio"
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
	"unicode"
)

// SearchResult represents a single search result
type SearchResult struct {
	Path    string `json:"path"`
	Title   string `json:"title"`
	Section string `json:"section"`
	Snippet string `json:"snippet,omitempty"`
}

// SearchDocuments performs full-text search across all markdown files
func (h *DocsHandler) SearchDocuments(query string) ([]SearchResult, error) {
	// Validate query
	query = strings.TrimSpace(query)
	if len(query) < 2 {
		return []SearchResult{}, nil
	}

	// Convert to lowercase for case-insensitive search
	queryLower := strings.ToLower(query)

	var results []SearchResult

	// Walk through all markdown files
	err := fs.WalkDir(h.docsFS, ".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		// Skip directories and non-markdown files
		if d.IsDir() || !strings.HasSuffix(path, ".md") {
			return nil
		}

		// Read file content
		content, err := fs.ReadFile(h.docsFS, path)
		if err != nil {
			// Log but continue - don't fail entire search
			fmt.Printf("Warning: failed to read %s: %v\n", path, err)
			return nil
		}

		// Extract title and check for matches
		title := h.extractTitle(content)
		section := h.getSectionFromPath(path)
		textContent := h.markdownToText(content)

		// Check if query matches
		if h.matchesQuery(textContent, queryLower) {
			result := SearchResult{
				Path:    path,
				Title:   title,
				Section: section,
			}

			// Generate snippet with highlighted match
			snippet := h.extractSnippet(textContent, queryLower)
			if snippet != "" {
				result.Snippet = snippet
			}

			results = append(results, result)
		}

		return nil
	})

	if err != nil {
		return nil, fmt.Errorf("failed to walk docs directory: %w", err)
	}

	return results, nil
}

// getSectionFromPath derives section name from file path
func (h *DocsHandler) getSectionFromPath(path string) string {
	// Remove .md extension
	path = strings.TrimSuffix(path, ".md")

	// Split by directory separators
	parts := strings.Split(path, string(filepath.Separator))

	// Filter out empty strings and index.md
	var filtered []string
	for _, part := range parts {
		if part != "" && part != "index.md" && part != "index" {
			// Convert kebab-case to Title Case
			filtered = append(filtered, kebabToTitle(part))
		}
	}

	// Join with " > "
	return strings.Join(filtered, " > ")
}

// kebabToTitle converts kebab-case to Title Case
func kebabToTitle(s string) string {
	words := strings.Split(s, "-")
	for i, word := range words {
		if len(word) > 0 {
			// Capitalize first letter
			runes := []rune(word)
			runes[0] = unicode.ToUpper(runes[0])
			words[i] = string(runes)
		}
	}
	return strings.Join(words, " ")
}

// markdownToText converts markdown content to plain text for searching
func (h *DocsHandler) markdownToText(content []byte) string {
	// Remove markdown syntax but keep text
	lines := strings.Split(string(content), "\n")
	var textLines []string

	for _, line := range lines {
		line = strings.TrimSpace(line)

		// Skip code blocks
		if strings.HasPrefix(line, "```") {
			continue
		}

		// Remove markdown headers
		line = strings.TrimLeft(line, "#")

		// Remove bold/italic markers
		line = strings.ReplaceAll(line, "**", "")
		line = strings.ReplaceAll(line, "*", "")
		line = strings.ReplaceAll(line, "__", "")
		line = strings.ReplaceAll(line, "_", "")

		// Remove links but keep text
		line = h.removeMarkdownLinks(line)

		// Remove code inline
		line = strings.ReplaceAll(line, "`", "")

		if line != "" {
			textLines = append(textLines, line)
		}
	}

	return strings.Join(textLines, " ")
}

// removeMarkdownLinks removes markdown link syntax
func (h *DocsHandler) removeMarkdownLinks(line string) string {
	// Simple approach: remove [text](url) patterns
	result := line
	for {
		start := strings.Index(result, "[")
		if start == -1 {
			break
		}
		end := strings.Index(result[start:], "](")
		if end == -1 {
			break
		}
		end += start + 2 // include "]("

		// Find closing paren
		closeParen := strings.Index(result[end:], ")")
		if closeParen == -1 {
			break
		}
		closeParen += end

		// Extract link text
		linkText := result[start+1 : start+end-start-2]

		// Replace entire link with just text
		result = result[:start] + linkText + result[closeParen+1:]
	}
	return result
}

// matchesQuery checks if content matches the search query
func (h *DocsHandler) matchesQuery(content, queryLower string) bool {
	contentLower := strings.ToLower(content)
	return strings.Contains(contentLower, queryLower)
}

// extractSnippet extracts a snippet around the matched term
func (h *DocsHandler) extractSnippet(content, queryLower string) string {
	contentLower := strings.ToLower(content)
	
	// Find first match
	idx := strings.Index(contentLower, queryLower)
	if idx == -1 {
		return ""
	}

	// Extract context around match (100 chars before and after)
	start := idx - 50
	if start < 0 {
		start = 0
	}
	end := idx + len(queryLower) + 50
	if end > len(content) {
		end = len(content)
	}

	snippet := content[start:end]
	
	// Add ellipsis if truncated
	if start > 0 {
		snippet = "..." + snippet
	}
	if end < len(content) {
		snippet = snippet + "..."
	}

	return snippet
}
```

### 2. Add HTTP Handler

**File**: `internal/docs/http_handler.go` (existing file - append to it)

```go
package docs

import (
	"encoding/json"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
)

// HandleSearch handles documentation search requests
func (h *DocsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
	// Extract query parameter
	query := r.URL.Query().Get("q")
	query = strings.TrimSpace(query)

	// Validate query
	if query == "" {
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"error": "query parameter 'q' is required",
		})
		return
	}

	if len(query) < 2 {
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"error": "query must be at least 2 characters",
		})
		return
	}

	// Perform search
	results, err := h.SearchDocuments(query)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"error": "failed to search documents",
		})
		return
	}

	// Return results
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{
		"query":   query,
		"count":   len(results),
		"results": results,
	})
}

// RegisterDocsSearchRoutes registers search endpoints
func RegisterDocsSearchRoutes(r chi.Router, handler *DocsHandler) {
	r.Get("/api/docs/search", handler.HandleSearch)
}
```

### 3. Register Route

**File**: `internal/router/docs.go` (modify existing)

Add the search route registration:

```go
func RegisterDocsRoutes(r chi.Router, docsPath string) {
	handler := docs.NewDocsHandler(docsPath)
	
	// Existing routes...
	r.Get("/docs", docsHandler)
	r.Get("/docs/*", docsHandler)
	
	// NEW: Search route
	docs.RegisterDocsSearchRoutes(r, handler)
}
```

---

## Frontend Changes

### 1. Update docs.ts to Remove Lunr

**File**: `web/src/docs.ts`

**Remove broken imports** (lines 1-4):
```typescript
// DELETE THESE LINES:
import { docs } from "../data/docs.json";
import searchIndex from "../data/search_index.json";
import * as lunr from "lunr";
import hljs from "highlight.js";
```

**Replace search function** (lines 46-88):

```typescript
// BEFORE:
function performDocsSearch(query: string): void {
  const searchResults = document.getElementById("docs-search-results");
  if (!searchResults) return;

  try {
    const idx = lunr.Builder.loadJs(searchIndex);
    // ... lunr search logic
  } catch (error) {
    console.error("Search error:", error);
  }
}

// AFTER:
async function performDocsSearch(query: string): Promise<void> {
  const searchResults = document.getElementById("docs-search-results");
  if (!searchResults) return;

  try {
    // Show loading state
    searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Searching...</p>';
    searchResults.classList.remove("hidden");

    // Fetch from backend API
    const response = await fetch(`/api/docs/search?q=${encodeURIComponent(query)}`);
    
    if (!response.ok) {
      throw new Error(`Search failed: ${response.statusText}`);
    }

    const data = await response.json();

    if (!data.results || data.results.length === 0) {
      searchResults.innerHTML =
        '<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
    } else {
      searchResults.innerHTML = data.results
        .slice(0, 10)
        .map((result: { path: string; title: string; section: string; snippet?: string }) => {
          return `
            <a href="/docs/${result.path}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
              <p class="font-medium text-sm" style="color: var(--text-primary)">${result.title || result.path}</p>
              ${result.section ? `<p class="text-xs" style="color: var(--text-secondary)">${result.section}</p>` : ""}
              ${result.snippet ? `<p class="text-xs mt-1" style="color: var(--text-secondary)">${result.snippet}</p>` : ""}
            </a>
          `;
        })
        .join("");
    }

    searchResults.classList.remove("hidden");
  } catch (error) {
    console.error("Search error:", error);
    searchResults.innerHTML =
      '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
    searchResults.classList.remove("hidden");
  }
}
```

**Keep the rest** - toggleSidebar, initializeDocsSearch, and Alpine registration remain the same.

### 2. Remove Lunr Dependency

**File**: `package.json`

**Remove from dependencies** (line 16):
```json
"lunr": "^2.3.9",  // DELETE THIS LINE
```

**Run**:
```bash
npm uninstall lunr
```

---

## Testing Strategy

### 1. Unit Tests (No API Required)

**File**: `internal/docs/search_test.go` (new file)

```go
package docs

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestMarkdownToText(t *testing.T) {
	handler := &DocsHandler{}

	testCases := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "Simple text",
			input:    "Hello world",
			expected: "Hello world",
		},
		{
			name:     "Remove headers",
			input:    "# Title\n\nSome content",
			expected: "Title Some content",
		},
		{
			name:     "Remove bold",
			input:    "This is **bold** text",
			expected: "This is bold text",
		},
		{
			name:     "Remove links",
			input:    "See [the docs](/docs/index.md) for more",
			expected: "See the docs for more",
		},
		{
			name:     "Remove code blocks",
			input:    "```go\nfunc test() {}\n```",
			expected: "",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := handler.markdownToText([]byte(tc.input))
			assert.Equal(t, tc.expected, result)
		})
	}
}

func TestGetSectionFromPath(t *testing.T) {
	handler := &DocsHandler{}

	testCases := []struct {
		name     string
		path     string
		expected string
	}{
		{
			name:     "API endpoint",
			path:     "developer/api/auth/login.md",
			expected: "Developer > Api > Auth > Login",
		},
		{
			name:     "User guide",
			path:     "user/devices/kobo-setup.md",
			expected: "User > Devices > Kobo Setup",
		},
		{
			name:     "Index file",
			path:     "index.md",
			expected: "",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := handler.getSectionFromPath(tc.path)
			assert.Equal(t, tc.expected, result)
		})
	}
}

func TestMatchesQuery(t *testing.T) {
	handler := &DocsHandler{}

	testCases := []struct {
		name     string
		content  string
		query    string
		expected bool
	}{
		{
			name:     "Exact match",
			content:  "Kobo setup guide for e-readers",
			query:    "kobo",
			expected: true,
		},
		{
			name:     "Case insensitive",
			content:  "Kobo Setup Guide",
			query:    "KOBO",
			expected: true,
		},
		{
			name:     "No match",
			content:  "iPhone setup guide",
			query:    "kobo",
			expected: false,
		},
		{
			name:     "Partial match",
			content:  "Configure your Kobo device",
			query:    "kobo device",
			expected: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := handler.matchesQuery(tc.content, tc.query)
			assert.Equal(t, tc.expected, result)
		})
	}
}

func TestExtractSnippet(t *testing.T) {
	handler := &DocsHandler{}

	testCases := []struct {
		name          string
		content       string
		query         string
		expectSnippet bool
	}{
		{
			name:          "Match in middle",
			content:       "This is some long text with Kobo device configuration options",
			query:         "kobo device",
			expectSnippet: true,
		},
		{
			name:          "No match",
			content:       "This is some text without the term",
			query:         "kobo",
			expectSnippet: false,
		},
		{
			name:          "Match at start",
			content:       "Kobo setup guide for e-readers with detailed instructions",
			query:         "kobo",
			expectSnippet: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := handler.extractSnippet(tc.content, tc.query)
			if tc.expectSnippet {
				assert.NotEmpty(t, result)
				assert.Contains(t, result, "...")
			} else {
				assert.Empty(t, result)
			}
		})
	}
}

func TestKebabToTitle(t *testing.T) {
	testCases := []struct {
		input    string
		expected string
	}{
		{"kobo-setup", "Kobo Setup"},
		{"api-reference", "Api Reference"},
		{"user-guide", "User Guide"},
		{"auth", "Auth"},
	}

	for _, tc := range testCases {
		t.Run(tc.input, func(t *testing.T) {
			result := kebabToTitle(tc.input)
			assert.Equal(t, tc.expected, result)
		})
	}
}
```

### 2. Integration Tests (with API)

**File**: `cmd/server/tests/docs_search_test.go` (new file)

```go
package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestDocsSearchAPI(t *testing.T) {
	// Setup test server
	ts := setupTestServer(t)
	defer ts.cleanup(t)

	testCases := []struct {
		name           string
		query          string
		expectedStatus int
		expectResults  bool
		description    string
	}{
		{
			name:           "Empty query",
			query:          "",
			expectedStatus: http.StatusBadRequest,
			expectResults:  false,
			description:    "Should reject empty query",
		},
		{
			name:           "Single character",
			query:          "a",
			expectedStatus: http.StatusBadRequest,
			expectResults:  false,
			description:    "Should reject queries < 2 chars",
		},
		{
			name:           "Valid search - kobo",
			query:          "kobo",
			expectedStatus: http.StatusOK,
			expectResults:  true,
			description:    "Should find Kobo documentation",
		},
		{
			name:           "Valid search - API",
			query:          "API",
			expectedStatus: http.StatusOK,
			expectResults:  true,
			description:    "Should find API documentation (case insensitive)",
		},
		{
			name:           "Valid search - no results",
			query:          "xyznonexistent",
			expectedStatus: http.StatusOK,
			expectResults:  false,
			description:    "Should return empty results for non-existent term",
		},
		{
			name:           "Valid search - sync",
			query:          "sync",
			expectedStatus: http.StatusOK,
			expectResults:  true,
			description:    "Should find sync documentation",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Create request
			req := httptest.NewRequest("GET", "/api/docs/search?q="+tc.query, nil)
			rr := httptest.NewRecorder()

			// Serve request
			ts.handler.ServeHTTP(rr, req)

			// Check status
			assert.Equal(t, tc.expectedStatus, rr.Code, tc.description)

			// Parse response
			var response map[string]interface{}
			err := json.Unmarshal(rr.Body.Bytes(), &response)
			require.NoError(t, err)

			// Validate response structure
			if tc.expectedStatus == http.StatusOK {
				assert.Contains(t, response, "query")
				assert.Contains(t, response, "count")
				assert.Contains(t, response, "results")

				results, ok := response["results"].([]interface{})
				require.True(t, ok, "results should be an array")

				if tc.expectResults {
					assert.Greater(t, len(results), 0, "Should have results")
					
					// Check first result structure
					if len(results) > 0 {
						firstResult, ok := results[0].(map[string]interface{})
						require.True(t, ok, "result should be an object")
						
						assert.Contains(t, firstResult, "path")
						assert.Contains(t, firstResult, "title")
						assert.Contains(t, firstResult, "section")
					}
				} else {
					assert.Equal(t, 0, len(results), "Should have no results")
				}
			} else {
				assert.Contains(t, response, "error")
			}
		})
	}
}

func TestDocsSearchResultStructure(t *testing.T) {
	ts := setupTestServer(t)
	defer ts.cleanup(t)

	// Search for a known term
	req := httptest.NewRequest("GET", "/api/docs/search?q=kobo", nil)
	rr := httptest.NewRecorder()

	ts.handler.ServeHTTP(rr, req)

	assert.Equal(t, http.StatusOK, rr.Code)

	var response map[string]interface{}
	err := json.Unmarshal(rr.Body.Bytes(), &response)
	require.NoError(t, err)

	results, ok := response["results"].([]interface{})
	require.True(t, ok)
	require.Greater(t, len(results), 0, "Should have at least one result for 'kobo'")

	// Validate first result has all required fields
	firstResult := results[0].(map[string]interface{})
	
	assert.Contains(t, firstResult, "path", "Should have path")
	assert.Contains(t, firstResult, "title", "Should have title")
	assert.Contains(t, firstResult, "section", "Should have section")
	
	// Check types
	assert.IsType(t, "", firstResult["path"])
	assert.IsType(t, "", firstResult["title"])
	assert.IsType(t, "", firstResult["section"])
	
	// Snippet is optional
	if snippet, ok := firstResult["snippet"]; ok {
		assert.IsType(t, "", snippet)
	}
}
```

**Note**: `setupTestServer()` helper should be called once at the test function level (not per subtest). If test_helpers.go doesn't exist yet, create it following existing test patterns in `cmd/server/tests/`.

### 3. Bruno OpenCollection YAML

**File**: `bruno/docs/Search Docs.yml` (new file)

```yaml
info:
  name: Search Docs
  type: http
  seq: 1

http:
  method: GET
  url: '{{base_url}}/api/docs/search?q=kobo'
  auth: none

docs: |-
  ## Search Documentation
  
  Performs full-text search across all documentation files.
  
  **Method:** GET
  
  **Endpoint:** /api/docs/search
  
  **Query Parameters:**
  - `q` (string, required): Search query (minimum 2 characters)
  
  **Authentication:** Not required (public documentation)
  
  **Response:**
  ```json
  {
    "query": "kobo",
    "count": 5,
    "results": [
      {
        "path": "user/devices/kobo-setup.md",
        "title": "Kobo Setup Guide",
        "section": "User > Devices > Kobo Setup",
        "snippet": "Complete Kobo e-reader configuration instructions..."
      }
    ]
  }
  ```
  
  **Status Codes:**
  - 200: Success (results may be empty)
  - 400: Bad request (missing or invalid query)
  - 500: Internal server error
  
  **Examples:**
  ```bash
  # Search for "kobo"
  GET /api/docs/search?q=kobo
  
  # Search for "API authentication"
  GET /api/docs/search?q=api%20authentication
  
  # Case insensitive
  GET /api/docs/search?q=KOBO
  
  # Minimum 2 characters
  GET /api/docs/search?q=a  # Returns 400 Bad Request
  ```

---

## Dependencies to Remove

### 1. Lunr (npm package)

**File**: `package.json`

**Before** (line 16):
```json
"lunr": "^2.3.9",
```

**After**: Delete this line

**Run**:
```bash
npm uninstall lunr
```

### 2. Clean Up docs.ts

**File**: `web/src/docs.ts`

**Remove imports** (lines 1-4):
```typescript
import { docs } from "../data/docs.json";
import searchIndex from "../data/search_index.json";
import * as lunr from "lunr";
import hljs from "highlight.js";
```

**Note**: `hljs` import can also be removed if not used elsewhere in the file.

---

## Documentation Updates

### 1. API Documentation

**File**: `docs/developer/api/docs/search.md` (new file)

```markdown
# Search Documentation

Search across all documentation files with full-text search.

## Endpoint

`GET /api/docs/search`

## Authentication

Not required (public documentation).

## Query Parameters

| Parameter | Type   | Required | Description                      |
|-----------|--------|----------|----------------------------------|
| q         | string | Yes      | Search query (min 2 characters) |

## Response

### Success (200 OK)

```json
{
  "query": "kobo",
  "count": 5,
  "results": [
    {
      "path": "user/devices/kobo-setup.md",
      "title": "Kobo Setup Guide",
      "section": "User > Devices > Kobo Setup",
      "snippet": "...Complete Kobo e-reader configuration..."
    }
  ]
}
```

### Bad Request (400)

Missing or invalid query:
```json
{
  "error": "query parameter 'q' is required"
}
```

Query too short:
```json
{
  "error": "query must be at least 2 characters"
}
```

## Examples

```bash
# Search for Kobo documentation
curl "https://bookhoard.com/api/docs/search?q=kobo"

# Search for API endpoints
curl "https://bookhoard.com/api/docs/search?q=api%20authentication"

# Multi-word search
curl "https://bookhoard.com/api/docs/search?q=device%20sync"
```

## Notes

- Search is case-insensitive
- Searches across title, section, and content
- Returns up to all matches (frontend can limit)
- Snippets include context around matched terms
- Results are not ranked (order depends on file system traversal)
```

### 2. Update Development Documentation

**File**: `docs/contributing/development.md`

Add section about the new search endpoint (if there's a section about API development or docs system).

### 3. Update README

**File**: `README.md`

If there's a section about documentation features, mention the new search functionality.

---

## Implementation Checklist

### Phase 1: Backend (1.5 hours)
- [ ] Create `internal/docs/search.go` with SearchDocuments method
- [ ] Add search handler to `internal/docs/http_handler.go`
- [ ] Register search route in `internal/router/docs.go`
- [ ] Write unit tests for search logic (`internal/docs/search_test.go`)
- [ ] Run unit tests: `go test ./internal/docs/... -v`
- [ ] Verify compilation: `go build ./...`

### Phase 2: Integration Testing (1 hour)
- [ ] Create `cmd/server/tests/docs_search_test.go`
- [ ] Implement table-driven integration tests
- [ ] Test all three contexts (no auth, user, admin) - though docs search doesn't require auth
- [ ] Run integration tests: `go test ./cmd/server/tests/... -v`
- [ ] Verify tests pass

### Phase 3: Frontend (1 hour)
- [ ] Update `web/src/docs.ts` to remove Lunr imports
- [ ] Replace performDocsSearch with async API call
- [ ] Test search in browser (manual testing)
- [ ] Verify no 404 errors for missing JSON files

### Phase 4: Cleanup (30 minutes)
- [ ] Remove Lunr from `package.json`
- [ ] Run `npm uninstall lunr`
- [ ] Rebuild frontend: `npm run build:ts`
- [ ] Verify no TypeScript errors
- [ ] Remove any references to `/web/src/data/` directory

### Phase 5: Documentation & Bruno (30 minutes)
- [ ] Create `bruno/docs/Search Docs.yml`
- [ ] Test endpoint with Bruno
- [ ] Create `docs/developer/api/docs/search.md`
- [ ] Update `docs/contributing/development.md` if needed
- [ ] Verify docs render at `/docs` endpoint
- [ ] Test docs search finds new API documentation

### Phase 6: Final Verification (30 minutes)
- [ ] Run full test suite: `go test ./... -v`
- [ ] Run verification script: `bash scripts/verify-guidelines.sh`
- [ ] Build entire project: `go build ./...`
- [ ] Test search in running application
- [ ] Verify all three test cases pass:
  - Empty query → 400 error
  - Valid search → results
  - No results → empty array
- [ ] Check git diff for unintended changes
- [ ] Review all modified files

---

## Git Commit Strategy

Follow PROJECT_GUIDELINES.md - make multiple logical commits:

1. **Backend implementation** (search.go, http_handler.go, router changes)
2. **Unit tests** (search_test.go)
3. **Integration tests** (docs_search_test.go)
4. **Frontend changes** (docs.ts updates, remove Lunr)
5. **Cleanup** (package.json, remove data directory references)
6. **Documentation** (API docs, Bruno YAML, contributing guide)

---

## Success Criteria

✅ Backend API `/api/docs/search` returns 200 with results  
✅ Empty/short queries return 400 Bad Request  
✅ Unit tests pass for all search helper functions  
✅ Integration tests pass with table-driven tests  
✅ Bruno YAML validates successfully  
✅ Frontend search works without errors  
✅ No 404s for missing JSON files  
✅ Lunr dependency removed from package.json  
✅ API documentation created and renders  
✅ Full test suite passes  
✅ Verification script passes (0 errors)  

---

## Rollback Plan

If issues arise:

1. **Backend**: Revert search.go, http_handler.go changes
2. **Tests**: Remove new test files
3. **Frontend**: Restore original docs.ts with Lunr code
4. **Dependencies**: Reinstall Lunr: `npm install lunr@^2.3.9`

Use `git diff` to identify changes and `git checkout` to revert specific files if needed.
