···262262263263A user can search Tangled content reliably with keyword search.
264264265265-## M5a — Search Site
265265+## M5a — Search Site ✅
266266267267refs: [specs/09-search-site.md](../specs/09-search-site.md)
268268···280280281281### Tasks
282282283283-- [ ] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories
284284-- [ ] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*`
285285-- [ ] Embed templates and static assets via `//go:embed`; parse templates once at init
286286-- [ ] Use a shared `layout.html` template for the shell (head, nav, footer)
287287-- [ ] Mount `view.Handler()` in the `api` package router as a fallback after API routes
288288-- [ ] Build search page:
283283+- [x] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories
284284+- [x] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*`
285285+- [x] Embed templates and static assets via `//go:embed`; parse templates once at init
286286+- [x] Use a shared `layout.html` template for the shell (head, nav, footer)
287287+- [x] Mount `view.Handler()` in the `api` package router as a fallback after API routes
288288+- [x] Build search page:
289289 - Text input + submit
290290 - Fetch `GET /search` with relative path (same origin)
291291 - Render result cards with type badge, title, snippet (preserve `<mark>`), author, repo, relative time
292292 - "Load more" pagination via offset
293293 - Filter bar: type, language, author (reflected in URL query params)
294294 - Empty and error states
295295-- [ ] Build API docs pages:
295295+- [x] Build API docs pages:
296296 - `/docs` — overview (base URL, response shape, no auth)
297297 - `/docs/search` — `GET /search` params, filters, example curl, example response
298298 - `/docs/documents` — `GET /documents/{id}` request/response
299299 - `/docs/health` — `GET /healthz`, `GET /readyz`
300300-- [ ] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.)
301301-- [ ] Load Google Sans and Google Sans Mono via Google Fonts `<link>`
302302-- [ ] Result card links open canonical Tangled URLs in new tab
303303-- [ ] Verify total site weight under 50 KB (excluding fonts and Alpine CDN)
300300+- [x] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.)
301301+- [x] Load Google Sans and Google Sans Mono via Google Fonts `<link>`
302302+- [x] Result card links open canonical Tangled URLs in new tab
303303+- [x] Verify total site weight under 50 KB (excluding fonts and Alpine CDN) — 21 KB total
304304305305### Verification
306306
+9-23
packages/api/internal/api/api.go
···1212 "tangled.org/desertthunder.dev/twister/internal/config"
1313 "tangled.org/desertthunder.dev/twister/internal/search"
1414 "tangled.org/desertthunder.dev/twister/internal/store"
1515+ "tangled.org/desertthunder.dev/twister/internal/view"
1516)
16171718// Server is the HTTP search API server.
···3637func (s *Server) Handler() http.Handler {
3738 mux := http.NewServeMux()
38393939- // Health
4040 mux.HandleFunc("GET /healthz", s.handleHealthz)
4141 mux.HandleFunc("GET /readyz", s.handleReadyz)
4242-4343- // Search — M5
4442 mux.HandleFunc("GET /search", s.handleSearch)
4543 mux.HandleFunc("GET /search/keyword", s.handleSearchKeyword)
4646-4747- // Search — placeholders (Phase 2/3)
4844 mux.HandleFunc("GET /search/semantic", s.handleNotImplemented)
4945 mux.HandleFunc("GET /search/hybrid", s.handleNotImplemented)
50465151- // Documents
5247 mux.HandleFunc("GET /documents/{id}", s.handleGetDocument)
53485454- // Admin — placeholders (M7)
5549 if s.cfg.EnableAdminEndpoints {
5650 mux.HandleFunc("POST /admin/reindex", s.handleNotImplemented)
5751 mux.HandleFunc("POST /admin/reembed", s.handleNotImplemented)
5852 }
5353+5454+ site := view.Handler()
5555+ mux.Handle("GET /static/", site)
5656+ mux.Handle("GET /docs", site)
5757+ mux.Handle("GET /docs/search", site)
5858+ mux.Handle("GET /docs/documents", site)
5959+ mux.Handle("GET /docs/health", site)
6060+ mux.Handle("GET /{$}", site)
59616062 return s.withMiddleware(mux)
6163}
···8587 }
8688}
87898888-// --- Middleware ---
8989-9090func (s *Server) withMiddleware(next http.Handler) http.Handler {
9191 return s.corsMiddleware(s.loggingMiddleware(next))
9292}
···128128 rw.status = code
129129 rw.ResponseWriter.WriteHeader(code)
130130}
131131-132132-// --- Health Handlers ---
133131134132func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
135133 writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
···144142 writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
145143}
146144147147-// --- Search Handlers ---
148148-149145// knownSearchParams is the whitelist of accepted query parameters for search endpoints.
150146var knownSearchParams = map[string]bool{
151147 "q": true, "mode": true, "limit": true, "offset": true,
···169165}
170166171167func (s *Server) handleSearchKeyword(w http.ResponseWriter, r *http.Request) {
172172- // Reject unknown parameters.
173168 for key := range r.URL.Query() {
174169 if !knownSearchParams[key] {
175170 writeJSON(w, http.StatusBadRequest, errorBody("unknown_parameter", fmt.Sprintf("unknown parameter: %s", key)))
···219214 writeJSON(w, http.StatusOK, resp)
220215}
221216222222-// --- Document Handler ---
223223-224217func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
225218 id := r.PathValue("id")
226219 if id == "" {
···228221 return
229222 }
230223231231- // Path value may be URL-encoded with | separators. The mux already decodes it,
232232- // but callers may use pipe-encoded or slash-separated IDs; accept as-is.
233224 doc, err := s.store.GetDocument(r.Context(), id)
234225 if err != nil {
235226 s.log.Error("get document failed", slog.String("error", err.Error()), slog.String("id", id))
···248239 writeJSON(w, http.StatusOK, documentResponse(doc))
249240}
250241251251-// --- Placeholder ---
252252-253242func (s *Server) handleNotImplemented(w http.ResponseWriter, _ *http.Request) {
254243 writeJSON(w, http.StatusNotImplemented, errorBody("not_implemented", "this endpoint is not yet available"))
255244}
256256-257257-// --- Helpers ---
258245259246func writeJSON(w http.ResponseWriter, status int, v any) {
260247 w.Header().Set("Content-Type", "application/json")
···319306 IndexedAt: doc.IndexedAt,
320307 }
321308}
322322-
···11+{{define "title"}}Health Endpoints — Twister API{{end}}
22+{{define "content"}}
33+<h1>Health Endpoints</h1>
44+<p>Twister exposes two health check endpoints for monitoring and orchestration.</p>
55+66+<h2><code>GET /healthz</code></h2>
77+<p>Liveness probe. Returns 200 if the process is responsive. No dependency checks.</p>
88+99+<h3>Example Request</h3>
1010+<pre><code>curl https://your-domain/healthz</code></pre>
1111+1212+<h3>Response</h3>
1313+<pre><code>{"status": "ok"}</code></pre>
1414+<p>Always returns <code>200 OK</code>.</p>
1515+1616+<h2><code>GET /readyz</code></h2>
1717+<p>Readiness probe. Checks that the database is reachable by executing a test query.</p>
1818+1919+<h3>Example Request</h3>
2020+<pre><code>curl https://your-domain/readyz</code></pre>
2121+2222+<h3>Response (healthy)</h3>
2323+<pre><code>{"status": "ready"}</code></pre>
2424+<p>Returns <code>200 OK</code> when the database is reachable.</p>
2525+2626+<h3>Response (unhealthy)</h3>
2727+<pre><code>{"error": "db_unreachable", "message": "database is not reachable"}</code></pre>
2828+<p>Returns <code>503 Service Unavailable</code> when the database cannot be reached.</p>
2929+3030+<h2>Usage</h2>
3131+<p>Configure your orchestrator (Railway, Kubernetes, etc.) to poll these endpoints:</p>
3232+<table>
3333+ <thead><tr><th>Endpoint</th><th>Purpose</th><th>Failure Action</th></tr></thead>
3434+ <tbody>
3535+ <tr><td><code>/healthz</code></td><td>Liveness</td><td>Restart the process</td></tr>
3636+ <tr><td><code>/readyz</code></td><td>Readiness</td><td>Remove from load balancer</td></tr>
3737+ </tbody>
3838+</table>
3939+{{end}}
4040+{{template "layout" .}}
···11+{{define "title"}}API Docs — Twister{{end}}
22+{{define "content"}}
33+<h1>API Documentation</h1>
44+<p>Twister exposes a public JSON API for searching indexed <a href="https://tangled.sh" target="_blank" rel="noopener">Tangled</a> content. No authentication is required for read endpoints.</p>
55+66+<h2>Base URL</h2>
77+<pre><code>https://<your-twister-domain></code></pre>
88+<p>All endpoints are relative to the base URL. When using the search site, the API is on the same origin.</p>
99+1010+<h2>Response Shape</h2>
1111+<p>All responses are JSON. Successful responses return the resource directly. Errors return:</p>
1212+<pre><code>{
1313+ "error": "error_code",
1414+ "message": "Human-readable description"
1515+}</code></pre>
1616+1717+<h2>Endpoints</h2>
1818+<table>
1919+ <thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
2020+ <tbody>
2121+ <tr><td><code>GET</code></td><td><a href="/docs/search"><code>/search</code></a></td><td>Search indexed documents</td></tr>
2222+ <tr><td><code>GET</code></td><td><a href="/docs/documents"><code>/documents/{id}</code></a></td><td>Fetch a single document</td></tr>
2323+ <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/healthz</code></a></td><td>Liveness probe</td></tr>
2424+ <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/readyz</code></a></td><td>Readiness probe</td></tr>
2525+ </tbody>
2626+</table>
2727+2828+<h2>Pagination</h2>
2929+<p>List endpoints use <code>limit</code> and <code>offset</code> query parameters. The default limit is 20, maximum is 100.</p>
3030+3131+<h2>Filtering</h2>
3232+<p>Search supports filters via query parameters: <code>collection</code>, <code>type</code>, <code>author</code>, <code>repo</code>, <code>language</code>, <code>state</code>, <code>from</code>, <code>to</code>. See the <a href="/docs/search">search endpoint docs</a> for details.</p>
3333+{{end}}
3434+{{template "layout" .}}