a love letter to tangled (android, iOS, and a search API)

feat: search site

+686 -35
+12 -12
docs/api/tasks/phase-1-mvp.md
··· 262 262 263 263 A user can search Tangled content reliably with keyword search. 264 264 265 - ## M5a — Search Site 265 + ## M5a — Search Site ✅ 266 266 267 267 refs: [specs/09-search-site.md](../specs/09-search-site.md) 268 268 ··· 280 280 281 281 ### Tasks 282 282 283 - - [ ] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories 284 - - [ ] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*` 285 - - [ ] Embed templates and static assets via `//go:embed`; parse templates once at init 286 - - [ ] Use a shared `layout.html` template for the shell (head, nav, footer) 287 - - [ ] Mount `view.Handler()` in the `api` package router as a fallback after API routes 288 - - [ ] Build search page: 283 + - [x] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories 284 + - [x] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*` 285 + - [x] Embed templates and static assets via `//go:embed`; parse templates once at init 286 + - [x] Use a shared `layout.html` template for the shell (head, nav, footer) 287 + - [x] Mount `view.Handler()` in the `api` package router as a fallback after API routes 288 + - [x] Build search page: 289 289 - Text input + submit 290 290 - Fetch `GET /search` with relative path (same origin) 291 291 - Render result cards with type badge, title, snippet (preserve `<mark>`), author, repo, relative time 292 292 - "Load more" pagination via offset 293 293 - Filter bar: type, language, author (reflected in URL query params) 294 294 - Empty and error states 295 - - [ ] Build API docs pages: 295 + - [x] Build API docs pages: 296 296 - `/docs` — overview (base URL, response shape, no auth) 297 297 - `/docs/search` — `GET /search` params, filters, example curl, example response 298 298 - `/docs/documents` — `GET /documents/{id}` request/response 299 299 - `/docs/health` — `GET /healthz`, `GET /readyz` 300 - - [ ] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.) 301 - - [ ] Load Google Sans and Google Sans Mono via Google Fonts `<link>` 302 - - [ ] Result card links open canonical Tangled URLs in new tab 303 - - [ ] Verify total site weight under 50 KB (excluding fonts and Alpine CDN) 300 + - [x] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.) 301 + - [x] Load Google Sans and Google Sans Mono via Google Fonts `<link>` 302 + - [x] Result card links open canonical Tangled URLs in new tab 303 + - [x] Verify total site weight under 50 KB (excluding fonts and Alpine CDN) — 21 KB total 304 304 305 305 ### Verification 306 306
+9 -23
packages/api/internal/api/api.go
··· 12 12 "tangled.org/desertthunder.dev/twister/internal/config" 13 13 "tangled.org/desertthunder.dev/twister/internal/search" 14 14 "tangled.org/desertthunder.dev/twister/internal/store" 15 + "tangled.org/desertthunder.dev/twister/internal/view" 15 16 ) 16 17 17 18 // Server is the HTTP search API server. ··· 36 37 func (s *Server) Handler() http.Handler { 37 38 mux := http.NewServeMux() 38 39 39 - // Health 40 40 mux.HandleFunc("GET /healthz", s.handleHealthz) 41 41 mux.HandleFunc("GET /readyz", s.handleReadyz) 42 - 43 - // Search — M5 44 42 mux.HandleFunc("GET /search", s.handleSearch) 45 43 mux.HandleFunc("GET /search/keyword", s.handleSearchKeyword) 46 - 47 - // Search — placeholders (Phase 2/3) 48 44 mux.HandleFunc("GET /search/semantic", s.handleNotImplemented) 49 45 mux.HandleFunc("GET /search/hybrid", s.handleNotImplemented) 50 46 51 - // Documents 52 47 mux.HandleFunc("GET /documents/{id}", s.handleGetDocument) 53 48 54 - // Admin — placeholders (M7) 55 49 if s.cfg.EnableAdminEndpoints { 56 50 mux.HandleFunc("POST /admin/reindex", s.handleNotImplemented) 57 51 mux.HandleFunc("POST /admin/reembed", s.handleNotImplemented) 58 52 } 53 + 54 + site := view.Handler() 55 + mux.Handle("GET /static/", site) 56 + mux.Handle("GET /docs", site) 57 + mux.Handle("GET /docs/search", site) 58 + mux.Handle("GET /docs/documents", site) 59 + mux.Handle("GET /docs/health", site) 60 + mux.Handle("GET /{$}", site) 59 61 60 62 return s.withMiddleware(mux) 61 63 } ··· 85 87 } 86 88 } 87 89 88 - // --- Middleware --- 89 - 90 90 func (s *Server) withMiddleware(next http.Handler) http.Handler { 91 91 return s.corsMiddleware(s.loggingMiddleware(next)) 92 92 } ··· 128 128 rw.status = code 129 129 rw.ResponseWriter.WriteHeader(code) 130 130 } 131 - 132 - // --- Health Handlers --- 133 131 134 132 func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { 135 133 writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) ··· 144 142 writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) 145 143 } 146 144 147 - // --- Search Handlers --- 148 - 149 145 // knownSearchParams is the whitelist of accepted query parameters for search endpoints. 150 146 var knownSearchParams = map[string]bool{ 151 147 "q": true, "mode": true, "limit": true, "offset": true, ··· 169 165 } 170 166 171 167 func (s *Server) handleSearchKeyword(w http.ResponseWriter, r *http.Request) { 172 - // Reject unknown parameters. 173 168 for key := range r.URL.Query() { 174 169 if !knownSearchParams[key] { 175 170 writeJSON(w, http.StatusBadRequest, errorBody("unknown_parameter", fmt.Sprintf("unknown parameter: %s", key))) ··· 219 214 writeJSON(w, http.StatusOK, resp) 220 215 } 221 216 222 - // --- Document Handler --- 223 - 224 217 func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { 225 218 id := r.PathValue("id") 226 219 if id == "" { ··· 228 221 return 229 222 } 230 223 231 - // Path value may be URL-encoded with | separators. The mux already decodes it, 232 - // but callers may use pipe-encoded or slash-separated IDs; accept as-is. 233 224 doc, err := s.store.GetDocument(r.Context(), id) 234 225 if err != nil { 235 226 s.log.Error("get document failed", slog.String("error", err.Error()), slog.String("id", id)) ··· 248 239 writeJSON(w, http.StatusOK, documentResponse(doc)) 249 240 } 250 241 251 - // --- Placeholder --- 252 - 253 242 func (s *Server) handleNotImplemented(w http.ResponseWriter, _ *http.Request) { 254 243 writeJSON(w, http.StatusNotImplemented, errorBody("not_implemented", "this endpoint is not yet available")) 255 244 } 256 - 257 - // --- Helpers --- 258 245 259 246 func writeJSON(w http.ResponseWriter, status int, v any) { 260 247 w.Header().Set("Content-Type", "application/json") ··· 319 306 IndexedAt: doc.IndexedAt, 320 307 } 321 308 } 322 -
+117
packages/api/internal/view/static/search.js
··· 1 + function searchApp() { 2 + return { 3 + query: "", 4 + filters: { type: "", author: "", language: "", state: "" }, 5 + results: [], 6 + offset: 0, 7 + limit: 20, 8 + total: 0, 9 + loading: false, 10 + searched: false, 11 + error: null, 12 + 13 + get hasMore() { 14 + return this.searched && this.offset + this.limit < this.total; 15 + }, 16 + 17 + initFromURL() { 18 + const p = new URLSearchParams(window.location.search); 19 + this.query = p.get("q") || ""; 20 + this.filters.type = p.get("type") || ""; 21 + this.filters.author = p.get("author") || ""; 22 + this.filters.language = p.get("language") || ""; 23 + this.filters.state = p.get("state") || ""; 24 + if (this.query) this.doSearch(true); 25 + }, 26 + 27 + buildParams(reset) { 28 + const p = new URLSearchParams(); 29 + p.set("q", this.query); 30 + p.set("limit", String(this.limit)); 31 + p.set("offset", String(reset ? 0 : this.offset)); 32 + if (this.filters.type) p.set("type", this.filters.type); 33 + if (this.filters.author) p.set("author", this.filters.author); 34 + if (this.filters.language) p.set("language", this.filters.language); 35 + if (this.filters.state) p.set("state", this.filters.state); 36 + return p; 37 + }, 38 + 39 + syncURL() { 40 + const p = new URLSearchParams(); 41 + if (this.query) p.set("q", this.query); 42 + if (this.filters.type) p.set("type", this.filters.type); 43 + if (this.filters.author) p.set("author", this.filters.author); 44 + if (this.filters.language) p.set("language", this.filters.language); 45 + if (this.filters.state) p.set("state", this.filters.state); 46 + const qs = p.toString(); 47 + history.replaceState(null, "", qs ? "?" + qs : "/"); 48 + }, 49 + 50 + async doSearch(reset) { 51 + if (!this.query.trim()) return; 52 + if (reset) { 53 + this.offset = 0; 54 + this.results = []; 55 + } 56 + this.loading = true; 57 + this.error = null; 58 + this.syncURL(); 59 + 60 + try { 61 + const resp = await fetch("/search?" + this.buildParams(reset)); 62 + if (!resp.ok) { 63 + const body = await resp.json().catch(() => null); 64 + this.error = (body && body.message) || "Search request failed (" + resp.status + ")"; 65 + return; 66 + } 67 + const data = await resp.json(); 68 + if (reset) { 69 + this.results = data.results || []; 70 + } else { 71 + this.results = this.results.concat(data.results || []); 72 + } 73 + this.total = data.total || 0; 74 + this.searched = true; 75 + } catch (e) { 76 + this.error = "Could not reach the API. Is the server running?"; 77 + } finally { 78 + this.loading = false; 79 + } 80 + }, 81 + 82 + loadMore() { 83 + this.offset += this.limit; 84 + this.doSearch(false); 85 + }, 86 + 87 + canonicalURL(r) { 88 + const h = r.author_handle || ""; 89 + switch (r.record_type) { 90 + case "repo": 91 + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name : "#"; 92 + case "issue": 93 + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/issues" : "#"; 94 + case "pull": 95 + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/pulls" : "#"; 96 + case "profile": 97 + return h ? "https://tangled.org/" + h : "#"; 98 + default: 99 + return "#"; 100 + } 101 + }, 102 + 103 + relTime(iso) { 104 + if (!iso) return ""; 105 + const diff = Date.now() - new Date(iso).getTime(); 106 + const mins = Math.floor(diff / 60000); 107 + if (mins < 1) return "just now"; 108 + if (mins < 60) return mins + "m ago"; 109 + const hrs = Math.floor(mins / 60); 110 + if (hrs < 24) return hrs + "h ago"; 111 + const days = Math.floor(hrs / 24); 112 + if (days < 30) return days + "d ago"; 113 + const months = Math.floor(days / 30); 114 + return months + "mo ago"; 115 + }, 116 + }; 117 + }
+206
packages/api/internal/view/static/style.css
··· 1 + :root { 2 + --bg: #0e0e0e; 3 + --surface: #1a1a1a; 4 + --border: #2a2a2a; 5 + --text: #e0e0e0; 6 + --text-dim: #888; 7 + --accent: #7aa2f7; 8 + --mark-bg: #7aa2f733; 9 + --mono: "Google Sans Mono", monospace; 10 + --sans: "Google Sans", sans-serif; 11 + --radius: 6px; 12 + } 13 + 14 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 15 + 16 + body { 17 + font-family: var(--sans); 18 + background: var(--bg); 19 + color: var(--text); 20 + line-height: 1.6; 21 + min-height: 100vh; 22 + display: flex; 23 + flex-direction: column; 24 + } 25 + 26 + a { color: var(--accent); text-decoration: none; } 27 + a:hover { text-decoration: underline; } 28 + 29 + /* Nav */ 30 + .nav { 31 + border-bottom: 1px solid var(--border); 32 + padding: .75rem 1rem; 33 + } 34 + .nav-inner { 35 + max-width: 720px; 36 + margin: 0 auto; 37 + display: flex; 38 + align-items: center; 39 + justify-content: space-between; 40 + } 41 + .nav-brand { 42 + font-family: var(--mono); 43 + font-weight: 700; 44 + font-size: 1.1rem; 45 + color: var(--text); 46 + } 47 + .nav-brand:hover { text-decoration: none; color: var(--accent); } 48 + .nav-links { display: flex; gap: 1.25rem; font-size: .9rem; } 49 + 50 + /* Main */ 51 + .main { 52 + max-width: 720px; 53 + width: 100%; 54 + margin: 0 auto; 55 + padding: 2rem 1rem; 56 + flex: 1; 57 + } 58 + 59 + /* Footer */ 60 + .footer { 61 + border-top: 1px solid var(--border); 62 + padding: 1rem; 63 + font-size: .8rem; 64 + color: var(--text-dim); 65 + } 66 + .footer-inner { max-width: 720px; margin: 0 auto; } 67 + 68 + /* Search hero */ 69 + .search-hero { margin-bottom: 1.5rem; } 70 + .search-hero h1 { font-size: 1.5rem; margin-bottom: 1rem; font-weight: 500; } 71 + .search-form { display: flex; gap: .5rem; margin-bottom: .75rem; } 72 + .search-input { 73 + flex: 1; 74 + padding: .6rem .75rem; 75 + background: var(--surface); 76 + border: 1px solid var(--border); 77 + border-radius: var(--radius); 78 + color: var(--text); 79 + font-family: var(--sans); 80 + font-size: .95rem; 81 + outline: none; 82 + } 83 + .search-input:focus { border-color: var(--accent); } 84 + .search-input::placeholder { color: var(--text-dim); } 85 + 86 + /* Buttons */ 87 + .btn { 88 + padding: .6rem 1.1rem; 89 + background: var(--surface); 90 + border: 1px solid var(--border); 91 + border-radius: var(--radius); 92 + color: var(--text); 93 + font-family: var(--sans); 94 + font-size: .9rem; 95 + cursor: pointer; 96 + } 97 + .btn:hover { border-color: var(--accent); color: var(--accent); } 98 + .btn:disabled { opacity: .5; cursor: default; } 99 + .btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 500; } 100 + .btn-primary:hover { background: #6b93e8; color: var(--bg); } 101 + .btn-more { width: 100%; margin-top: .75rem; } 102 + 103 + /* Filter bar */ 104 + .filter-bar { display: flex; gap: .5rem; flex-wrap: wrap; } 105 + .filter-bar select, 106 + .filter-input { 107 + padding: .45rem .6rem; 108 + background: var(--surface); 109 + border: 1px solid var(--border); 110 + border-radius: var(--radius); 111 + color: var(--text); 112 + font-family: var(--sans); 113 + font-size: .85rem; 114 + outline: none; 115 + } 116 + .filter-bar select:focus, 117 + .filter-input:focus { border-color: var(--accent); } 118 + .filter-input { width: 140px; } 119 + .filter-input::placeholder { color: var(--text-dim); } 120 + 121 + /* Messages */ 122 + .msg { padding: 1rem; color: var(--text-dim); text-align: center; } 123 + .msg-error { color: #f7768e; } 124 + 125 + /* Result cards */ 126 + .card { 127 + display: block; 128 + background: var(--surface); 129 + border: 1px solid var(--border); 130 + border-radius: var(--radius); 131 + padding: .85rem 1rem; 132 + margin-bottom: .6rem; 133 + color: var(--text); 134 + transition: border-color .15s; 135 + } 136 + .card:hover { border-color: var(--accent); text-decoration: none; } 137 + .card-head { display: flex; align-items: center; gap: .5rem; margin-bottom: .35rem; } 138 + .badge { 139 + font-family: var(--mono); 140 + font-size: .7rem; 141 + padding: .15rem .45rem; 142 + background: var(--border); 143 + border-radius: 3px; 144 + text-transform: uppercase; 145 + color: var(--text-dim); 146 + white-space: nowrap; 147 + } 148 + .card-title { font-weight: 500; font-size: .95rem; } 149 + .card-snippet { 150 + font-size: .85rem; 151 + color: var(--text-dim); 152 + margin-bottom: .35rem; 153 + line-height: 1.5; 154 + } 155 + .card-snippet mark { 156 + background: var(--mark-bg); 157 + color: var(--accent); 158 + padding: 0 .1rem; 159 + border-radius: 2px; 160 + } 161 + .card-meta { font-size: .78rem; color: var(--text-dim); display: flex; gap: .5rem; } 162 + .meta-sep::before { content: "\00b7"; margin-right: .5rem; } 163 + 164 + /* Docs */ 165 + .main h1 { font-size: 1.4rem; margin-bottom: 1rem; font-weight: 500; } 166 + .main h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; font-weight: 500; } 167 + .main h3 { font-size: .95rem; margin: 1rem 0 .4rem; font-weight: 500; } 168 + .main p { margin-bottom: .75rem; } 169 + .main table { 170 + width: 100%; 171 + border-collapse: collapse; 172 + margin-bottom: 1rem; 173 + font-size: .85rem; 174 + } 175 + .main th, .main td { 176 + text-align: left; 177 + padding: .45rem .6rem; 178 + border-bottom: 1px solid var(--border); 179 + } 180 + .main th { color: var(--text-dim); font-weight: 500; } 181 + .main code { 182 + font-family: var(--mono); 183 + font-size: .85em; 184 + background: var(--surface); 185 + padding: .1rem .35rem; 186 + border-radius: 3px; 187 + } 188 + .main pre { 189 + background: var(--surface); 190 + border: 1px solid var(--border); 191 + border-radius: var(--radius); 192 + padding: .85rem 1rem; 193 + overflow-x: auto; 194 + margin-bottom: 1rem; 195 + font-size: .82rem; 196 + line-height: 1.5; 197 + } 198 + .main pre code { background: none; padding: 0; } 199 + 200 + /* Mobile */ 201 + @media (max-width: 640px) { 202 + .search-form { flex-direction: column; } 203 + .filter-bar { flex-direction: column; } 204 + .filter-input { width: 100%; } 205 + .card-meta { flex-wrap: wrap; } 206 + }
+48
packages/api/internal/view/templates/docs/documents.html
··· 1 + {{define "title"}}GET /documents/{id} &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1><code>GET /documents/{id}</code></h1> 4 + <p>Fetch a single indexed document by its stable ID.</p> 5 + 6 + <h2>Path Parameters</h2> 7 + <table> 8 + <thead><tr><th>Name</th><th>Type</th><th>Description</th></tr></thead> 9 + <tbody> 10 + <tr><td><code>id</code></td><td>string</td><td>Document stable ID (format: <code>did|collection|rkey</code>).</td></tr> 11 + </tbody> 12 + </table> 13 + 14 + <h2>Example Request</h2> 15 + <pre><code>curl "https://your-domain/documents/did:plc:abc|sh.tangled.repo|3kb3fge5lm32x"</code></pre> 16 + 17 + <h2>Example Response</h2> 18 + <pre><code>{ 19 + "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x", 20 + "did": "did:plc:abc", 21 + "collection": "sh.tangled.repo", 22 + "rkey": "3kb3fge5lm32x", 23 + "at_uri": "at://did:plc:abc/sh.tangled.repo/3kb3fge5lm32x", 24 + "cid": "bafyreig...", 25 + "record_type": "repo", 26 + "title": "glow-rs", 27 + "body": "A TUI markdown viewer inspired by Glow, written in Rust.", 28 + "summary": "Rust TUI markdown viewer", 29 + "repo_name": "glow-rs", 30 + "author_handle": "desertthunder.dev", 31 + "tags_json": "[\"rust\", \"tui\", \"markdown\"]", 32 + "language": "en", 33 + "created_at": "2026-03-20T10:00:00Z", 34 + "updated_at": "2026-03-22T15:03:11Z", 35 + "indexed_at": "2026-03-22T15:05:00Z" 36 + }</code></pre> 37 + 38 + <h2>Errors</h2> 39 + <table> 40 + <thead><tr><th>Status</th><th>Condition</th></tr></thead> 41 + <tbody> 42 + <tr><td>400</td><td>Missing or empty document ID.</td></tr> 43 + <tr><td>404</td><td>Document not found or has been deleted.</td></tr> 44 + <tr><td>500</td><td>Database error.</td></tr> 45 + </tbody> 46 + </table> 47 + {{end}} 48 + {{template "layout" .}}
+40
packages/api/internal/view/templates/docs/health.html
··· 1 + {{define "title"}}Health Endpoints &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Health Endpoints</h1> 4 + <p>Twister exposes two health check endpoints for monitoring and orchestration.</p> 5 + 6 + <h2><code>GET /healthz</code></h2> 7 + <p>Liveness probe. Returns 200 if the process is responsive. No dependency checks.</p> 8 + 9 + <h3>Example Request</h3> 10 + <pre><code>curl https://your-domain/healthz</code></pre> 11 + 12 + <h3>Response</h3> 13 + <pre><code>{"status": "ok"}</code></pre> 14 + <p>Always returns <code>200 OK</code>.</p> 15 + 16 + <h2><code>GET /readyz</code></h2> 17 + <p>Readiness probe. Checks that the database is reachable by executing a test query.</p> 18 + 19 + <h3>Example Request</h3> 20 + <pre><code>curl https://your-domain/readyz</code></pre> 21 + 22 + <h3>Response (healthy)</h3> 23 + <pre><code>{"status": "ready"}</code></pre> 24 + <p>Returns <code>200 OK</code> when the database is reachable.</p> 25 + 26 + <h3>Response (unhealthy)</h3> 27 + <pre><code>{"error": "db_unreachable", "message": "database is not reachable"}</code></pre> 28 + <p>Returns <code>503 Service Unavailable</code> when the database cannot be reached.</p> 29 + 30 + <h2>Usage</h2> 31 + <p>Configure your orchestrator (Railway, Kubernetes, etc.) to poll these endpoints:</p> 32 + <table> 33 + <thead><tr><th>Endpoint</th><th>Purpose</th><th>Failure Action</th></tr></thead> 34 + <tbody> 35 + <tr><td><code>/healthz</code></td><td>Liveness</td><td>Restart the process</td></tr> 36 + <tr><td><code>/readyz</code></td><td>Readiness</td><td>Remove from load balancer</td></tr> 37 + </tbody> 38 + </table> 39 + {{end}} 40 + {{template "layout" .}}
+34
packages/api/internal/view/templates/docs/index.html
··· 1 + {{define "title"}}API Docs &mdash; Twister{{end}} 2 + {{define "content"}} 3 + <h1>API Documentation</h1> 4 + <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> 5 + 6 + <h2>Base URL</h2> 7 + <pre><code>https://&lt;your-twister-domain&gt;</code></pre> 8 + <p>All endpoints are relative to the base URL. When using the search site, the API is on the same origin.</p> 9 + 10 + <h2>Response Shape</h2> 11 + <p>All responses are JSON. Successful responses return the resource directly. Errors return:</p> 12 + <pre><code>{ 13 + "error": "error_code", 14 + "message": "Human-readable description" 15 + }</code></pre> 16 + 17 + <h2>Endpoints</h2> 18 + <table> 19 + <thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead> 20 + <tbody> 21 + <tr><td><code>GET</code></td><td><a href="/docs/search"><code>/search</code></a></td><td>Search indexed documents</td></tr> 22 + <tr><td><code>GET</code></td><td><a href="/docs/documents"><code>/documents/{id}</code></a></td><td>Fetch a single document</td></tr> 23 + <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/healthz</code></a></td><td>Liveness probe</td></tr> 24 + <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/readyz</code></a></td><td>Readiness probe</td></tr> 25 + </tbody> 26 + </table> 27 + 28 + <h2>Pagination</h2> 29 + <p>List endpoints use <code>limit</code> and <code>offset</code> query parameters. The default limit is 20, maximum is 100.</p> 30 + 31 + <h2>Filtering</h2> 32 + <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> 33 + {{end}} 34 + {{template "layout" .}}
+74
packages/api/internal/view/templates/docs/search.html
··· 1 + {{define "title"}}GET /search &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1><code>GET /search</code></h1> 4 + <p>Search indexed Tangled documents with keyword matching. Returns a paginated list of results with highlighted snippets.</p> 5 + 6 + <h2>Parameters</h2> 7 + <table> 8 + <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> 9 + <tbody> 10 + <tr><td><code>q</code></td><td>string</td><td><em>required</em></td><td>Search query. Supports boolean operators, phrases, and prefix matching.</td></tr> 11 + <tr><td><code>mode</code></td><td>string</td><td><code>keyword</code></td><td>Search mode: <code>keyword</code>, <code>semantic</code>, or <code>hybrid</code>.</td></tr> 12 + <tr><td><code>limit</code></td><td>int</td><td><code>20</code></td><td>Results per page (1&ndash;100).</td></tr> 13 + <tr><td><code>offset</code></td><td>int</td><td><code>0</code></td><td>Pagination offset.</td></tr> 14 + <tr><td><code>collection</code></td><td>string</td><td>&mdash;</td><td>Filter by ATProto collection NSID.</td></tr> 15 + <tr><td><code>type</code></td><td>string</td><td>&mdash;</td><td>Filter by record type: <code>repo</code>, <code>issue</code>, <code>pull</code>, <code>profile</code>, <code>string</code>.</td></tr> 16 + <tr><td><code>author</code></td><td>string</td><td>&mdash;</td><td>Filter by author handle or DID.</td></tr> 17 + <tr><td><code>repo</code></td><td>string</td><td>&mdash;</td><td>Filter by repo name.</td></tr> 18 + <tr><td><code>language</code></td><td>string</td><td>&mdash;</td><td>Filter by programming language.</td></tr> 19 + <tr><td><code>state</code></td><td>string</td><td>&mdash;</td><td>Filter by state: <code>open</code>, <code>closed</code>, <code>merged</code>.</td></tr> 20 + <tr><td><code>from</code></td><td>string</td><td>&mdash;</td><td>Created after (ISO 8601).</td></tr> 21 + <tr><td><code>to</code></td><td>string</td><td>&mdash;</td><td>Created before (ISO 8601).</td></tr> 22 + </tbody> 23 + </table> 24 + 25 + <h2>Example Request</h2> 26 + <pre><code>curl "https://your-domain/search?q=rust+tui&type=repo&limit=5"</code></pre> 27 + 28 + <h2>Example Response</h2> 29 + <pre><code>{ 30 + "query": "rust tui", 31 + "mode": "keyword", 32 + "total": 12, 33 + "limit": 5, 34 + "offset": 0, 35 + "results": [ 36 + { 37 + "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x", 38 + "collection": "sh.tangled.repo", 39 + "record_type": "repo", 40 + "title": "glow-rs", 41 + "body_snippet": "A &lt;mark&gt;TUI&lt;/mark&gt; markdown viewer written in &lt;mark&gt;Rust&lt;/mark&gt;...", 42 + "summary": "Rust TUI markdown viewer", 43 + "repo_name": "glow-rs", 44 + "author_handle": "desertthunder.dev", 45 + "score": 0.842, 46 + "created_at": "2026-03-20T10:00:00Z", 47 + "updated_at": "2026-03-22T15:03:11Z" 48 + } 49 + ] 50 + }</code></pre> 51 + 52 + <h2>Query Syntax</h2> 53 + <p>The keyword search uses Tantivy query syntax:</p> 54 + <table> 55 + <thead><tr><th>Feature</th><th>Example</th></tr></thead> 56 + <tbody> 57 + <tr><td>Boolean AND</td><td><code>go AND search</code></td></tr> 58 + <tr><td>Boolean NOT</td><td><code>rust NOT unsafe</code></td></tr> 59 + <tr><td>Phrase</td><td><code>"pull request"</code></td></tr> 60 + <tr><td>Prefix</td><td><code>tang*</code></td></tr> 61 + <tr><td>Field-scoped</td><td><code>title:parser</code></td></tr> 62 + </tbody> 63 + </table> 64 + 65 + <h2>Errors</h2> 66 + <table> 67 + <thead><tr><th>Status</th><th>Condition</th></tr></thead> 68 + <tbody> 69 + <tr><td>400</td><td>Missing <code>q</code>, invalid <code>limit</code>/<code>offset</code>, unknown parameters.</td></tr> 70 + <tr><td>500</td><td>Internal search failure.</td></tr> 71 + </tbody> 72 + </table> 73 + {{end}} 74 + {{template "layout" .}}
+64
packages/api/internal/view/templates/index.html
··· 1 + {{define "title"}}Twister &mdash; Search Tangled{{end}} 2 + {{define "head"}} 3 + <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> 4 + {{end}} 5 + {{define "content"}} 6 + <div x-data="searchApp()" x-init="initFromURL()"> 7 + <section class="search-hero"> 8 + <h1>Search Tangled</h1> 9 + <form class="search-form" @submit.prevent="doSearch(true)"> 10 + <input type="text" x-model="query" placeholder="Search repos, issues, PRs, profiles&hellip;" class="search-input" autofocus> 11 + <button type="submit" class="btn btn-primary">Search</button> 12 + </form> 13 + <div class="filter-bar"> 14 + <select x-model="filters.type" @change="doSearch(true)"> 15 + <option value="">All types</option> 16 + <option value="repo">Repos</option> 17 + <option value="issue">Issues</option> 18 + <option value="pull">Pull Requests</option> 19 + <option value="profile">Profiles</option> 20 + <option value="string">Strings</option> 21 + <option value="star">Stars</option> 22 + </select> 23 + <input type="text" x-model="filters.author" placeholder="Author" @keydown.enter="doSearch(true)" class="filter-input"> 24 + <input type="text" x-model="filters.language" placeholder="Language" @keydown.enter="doSearch(true)" class="filter-input"> 25 + <select x-model="filters.state" @change="doSearch(true)"> 26 + <option value="">Any state</option> 27 + <option value="open">Open</option> 28 + <option value="closed">Closed</option> 29 + <option value="merged">Merged</option> 30 + </select> 31 + </div> 32 + </section> 33 + 34 + <section class="results"> 35 + <template x-if="error"> 36 + <div class="msg msg-error" x-text="error"></div> 37 + </template> 38 + <template x-if="!error && searched && results.length === 0"> 39 + <div class="msg">No results found.</div> 40 + </template> 41 + <template x-for="r in results" :key="r.id"> 42 + <a :href="canonicalURL(r)" target="_blank" rel="noopener" class="card"> 43 + <div class="card-head"> 44 + <span class="badge" x-text="r.record_type"></span> 45 + <span class="card-title" x-text="r.title || r.id"></span> 46 + </div> 47 + <div class="card-snippet" x-show="r.body_snippet" x-html="r.body_snippet"></div> 48 + <div class="card-meta"> 49 + <span x-show="r.author_handle" x-text="r.author_handle"></span> 50 + <span x-show="r.repo_name" class="meta-sep" x-text="r.repo_name"></span> 51 + <span x-show="r.updated_at" class="meta-sep" x-text="relTime(r.updated_at)"></span> 52 + </div> 53 + </a> 54 + </template> 55 + <template x-if="hasMore"> 56 + <button class="btn btn-more" @click="loadMore()" x-text="loading ? 'Loading\u2026' : 'Load more'" :disabled="loading"></button> 57 + </template> 58 + </section> 59 + </div> 60 + {{end}} 61 + {{define "scripts"}} 62 + <script src="/static/search.js"></script> 63 + {{end}} 64 + {{template "layout" .}}
+33
packages/api/internal/view/templates/layout.html
··· 1 + {{define "layout"}}<!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{{block "title" .}}Twister{{end}}</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 + <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Mono:wght@400;500&display=swap" rel="stylesheet"> 10 + <link rel="stylesheet" href="/static/style.css"> 11 + {{block "head" .}}{{end}} 12 + </head> 13 + <body> 14 + <nav class="nav"> 15 + <div class="nav-inner"> 16 + <a href="/" class="nav-brand">twister</a> 17 + <div class="nav-links"> 18 + <a href="/">Search</a> 19 + <a href="/docs">API Docs</a> 20 + </div> 21 + </div> 22 + </nav> 23 + <main class="main"> 24 + {{block "content" .}}{{end}} 25 + </main> 26 + <footer class="footer"> 27 + <div class="footer-inner"> 28 + <span>Twister &mdash; search for <a href="https://tangled.sh" target="_blank" rel="noopener">Tangled</a></span> 29 + </div> 30 + </footer> 31 + {{block "scripts" .}}{{end}} 32 + </body> 33 + </html>{{end}}
+49
packages/api/internal/view/view.go
··· 1 + package view 2 + 3 + import ( 4 + "embed" 5 + "html/template" 6 + "io/fs" 7 + "net/http" 8 + ) 9 + 10 + //go:embed templates static 11 + var content embed.FS 12 + 13 + var templates *template.Template 14 + 15 + func init() { 16 + templates = template.Must(template.ParseFS(content, 17 + "templates/layout.html", 18 + "templates/index.html", 19 + "templates/docs/index.html", 20 + "templates/docs/search.html", 21 + "templates/docs/documents.html", 22 + "templates/docs/health.html", 23 + )) 24 + } 25 + 26 + // Handler returns an http.Handler that serves the site pages and static assets. 27 + func Handler() http.Handler { 28 + mux := http.NewServeMux() 29 + 30 + staticFS, _ := fs.Sub(content, "static") 31 + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS))) 32 + 33 + mux.HandleFunc("GET /docs/search", renderPage("docs/search.html")) 34 + mux.HandleFunc("GET /docs/documents", renderPage("docs/documents.html")) 35 + mux.HandleFunc("GET /docs/health", renderPage("docs/health.html")) 36 + mux.HandleFunc("GET /docs", renderPage("docs/index.html")) 37 + mux.HandleFunc("GET /{$}", renderPage("index.html")) 38 + 39 + return mux 40 + } 41 + 42 + func renderPage(name string) http.HandlerFunc { 43 + return func(w http.ResponseWriter, r *http.Request) { 44 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 45 + if err := templates.ExecuteTemplate(w, name, nil); err != nil { 46 + http.Error(w, "template error", http.StatusInternalServerError) 47 + } 48 + } 49 + }