diff --git a/docs/api/tasks/phase-1-mvp.md b/docs/api/tasks/phase-1-mvp.md index 68699be..5f866e3 100644 --- a/docs/api/tasks/phase-1-mvp.md +++ b/docs/api/tasks/phase-1-mvp.md @@ -262,7 +262,7 @@ Expose a usable public search API backed by Turso's Tantivy-backed FTS. A user can search Tangled content reliably with keyword search. -## M5a — Search Site +## M5a — Search Site ✅ refs: [specs/09-search-site.md](../specs/09-search-site.md) @@ -280,27 +280,27 @@ Ship a static site that doubles as public API documentation and a live search de ### Tasks -- [ ] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories -- [ ] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*` -- [ ] Embed templates and static assets via `//go:embed`; parse templates once at init -- [ ] Use a shared `layout.html` template for the shell (head, nav, footer) -- [ ] Mount `view.Handler()` in the `api` package router as a fallback after API routes -- [ ] Build search page: +- [x] Create `internal/view/` package with `view.go`, `templates/`, and `static/` directories +- [x] Implement `Handler()` that returns an `http.Handler` with routes for all pages and `/static/*` +- [x] Embed templates and static assets via `//go:embed`; parse templates once at init +- [x] Use a shared `layout.html` template for the shell (head, nav, footer) +- [x] Mount `view.Handler()` in the `api` package router as a fallback after API routes +- [x] Build search page: - Text input + submit - Fetch `GET /search` with relative path (same origin) - Render result cards with type badge, title, snippet (preserve ``), author, repo, relative time - "Load more" pagination via offset - Filter bar: type, language, author (reflected in URL query params) - Empty and error states -- [ ] Build API docs pages: +- [x] Build API docs pages: - `/docs` — overview (base URL, response shape, no auth) - `/docs/search` — `GET /search` params, filters, example curl, example response - `/docs/documents` — `GET /documents/{id}` request/response - `/docs/health` — `GET /healthz`, `GET /readyz` -- [ ] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.) -- [ ] Load Google Sans and Google Sans Mono via Google Fonts `` -- [ ] Result card links open canonical Tangled URLs in new tab -- [ ] Verify total site weight under 50 KB (excluding fonts and Alpine CDN) +- [x] Implement `style.css` with design tokens (`--bg`, `--surface`, `--border`, `--accent`, etc.) +- [x] Load Google Sans and Google Sans Mono via Google Fonts `` +- [x] Result card links open canonical Tangled URLs in new tab +- [x] Verify total site weight under 50 KB (excluding fonts and Alpine CDN) — 21 KB total ### Verification diff --git a/packages/api/internal/api/api.go b/packages/api/internal/api/api.go index a3523b4..bac5e52 100644 --- a/packages/api/internal/api/api.go +++ b/packages/api/internal/api/api.go @@ -12,6 +12,7 @@ import ( "tangled.org/desertthunder.dev/twister/internal/config" "tangled.org/desertthunder.dev/twister/internal/search" "tangled.org/desertthunder.dev/twister/internal/store" + "tangled.org/desertthunder.dev/twister/internal/view" ) // Server is the HTTP search API server. @@ -36,27 +37,28 @@ func New(searchRepo *search.Repository, st store.Store, cfg *config.Config, log func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - // Health mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /readyz", s.handleReadyz) - - // Search — M5 mux.HandleFunc("GET /search", s.handleSearch) mux.HandleFunc("GET /search/keyword", s.handleSearchKeyword) - - // Search — placeholders (Phase 2/3) mux.HandleFunc("GET /search/semantic", s.handleNotImplemented) mux.HandleFunc("GET /search/hybrid", s.handleNotImplemented) - // Documents mux.HandleFunc("GET /documents/{id}", s.handleGetDocument) - // Admin — placeholders (M7) if s.cfg.EnableAdminEndpoints { mux.HandleFunc("POST /admin/reindex", s.handleNotImplemented) mux.HandleFunc("POST /admin/reembed", s.handleNotImplemented) } + site := view.Handler() + mux.Handle("GET /static/", site) + mux.Handle("GET /docs", site) + mux.Handle("GET /docs/search", site) + mux.Handle("GET /docs/documents", site) + mux.Handle("GET /docs/health", site) + mux.Handle("GET /{$}", site) + return s.withMiddleware(mux) } @@ -85,8 +87,6 @@ func (s *Server) Run(ctx context.Context) error { } } -// --- Middleware --- - func (s *Server) withMiddleware(next http.Handler) http.Handler { return s.corsMiddleware(s.loggingMiddleware(next)) } @@ -129,8 +129,6 @@ func (rw *responseWriter) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } -// --- Health Handlers --- - func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } @@ -144,8 +142,6 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } -// --- Search Handlers --- - // knownSearchParams is the whitelist of accepted query parameters for search endpoints. var knownSearchParams = map[string]bool{ "q": true, "mode": true, "limit": true, "offset": true, @@ -169,7 +165,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSearchKeyword(w http.ResponseWriter, r *http.Request) { - // Reject unknown parameters. for key := range r.URL.Query() { if !knownSearchParams[key] { writeJSON(w, http.StatusBadRequest, errorBody("unknown_parameter", fmt.Sprintf("unknown parameter: %s", key))) @@ -219,8 +214,6 @@ func (s *Server) handleSearchKeyword(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -// --- Document Handler --- - func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { @@ -228,8 +221,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { return } - // Path value may be URL-encoded with | separators. The mux already decodes it, - // but callers may use pipe-encoded or slash-separated IDs; accept as-is. doc, err := s.store.GetDocument(r.Context(), id) if err != nil { s.log.Error("get document failed", slog.String("error", err.Error()), slog.String("id", id)) @@ -248,14 +239,10 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, documentResponse(doc)) } -// --- Placeholder --- - func (s *Server) handleNotImplemented(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusNotImplemented, errorBody("not_implemented", "this endpoint is not yet available")) } -// --- Helpers --- - func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -319,4 +306,3 @@ func documentResponse(doc *store.Document) documentJSON { IndexedAt: doc.IndexedAt, } } - diff --git a/packages/api/internal/view/static/search.js b/packages/api/internal/view/static/search.js new file mode 100644 index 0000000..2effd29 --- /dev/null +++ b/packages/api/internal/view/static/search.js @@ -0,0 +1,117 @@ +function searchApp() { + return { + query: "", + filters: { type: "", author: "", language: "", state: "" }, + results: [], + offset: 0, + limit: 20, + total: 0, + loading: false, + searched: false, + error: null, + + get hasMore() { + return this.searched && this.offset + this.limit < this.total; + }, + + initFromURL() { + const p = new URLSearchParams(window.location.search); + this.query = p.get("q") || ""; + this.filters.type = p.get("type") || ""; + this.filters.author = p.get("author") || ""; + this.filters.language = p.get("language") || ""; + this.filters.state = p.get("state") || ""; + if (this.query) this.doSearch(true); + }, + + buildParams(reset) { + const p = new URLSearchParams(); + p.set("q", this.query); + p.set("limit", String(this.limit)); + p.set("offset", String(reset ? 0 : this.offset)); + if (this.filters.type) p.set("type", this.filters.type); + if (this.filters.author) p.set("author", this.filters.author); + if (this.filters.language) p.set("language", this.filters.language); + if (this.filters.state) p.set("state", this.filters.state); + return p; + }, + + syncURL() { + const p = new URLSearchParams(); + if (this.query) p.set("q", this.query); + if (this.filters.type) p.set("type", this.filters.type); + if (this.filters.author) p.set("author", this.filters.author); + if (this.filters.language) p.set("language", this.filters.language); + if (this.filters.state) p.set("state", this.filters.state); + const qs = p.toString(); + history.replaceState(null, "", qs ? "?" + qs : "/"); + }, + + async doSearch(reset) { + if (!this.query.trim()) return; + if (reset) { + this.offset = 0; + this.results = []; + } + this.loading = true; + this.error = null; + this.syncURL(); + + try { + const resp = await fetch("/search?" + this.buildParams(reset)); + if (!resp.ok) { + const body = await resp.json().catch(() => null); + this.error = (body && body.message) || "Search request failed (" + resp.status + ")"; + return; + } + const data = await resp.json(); + if (reset) { + this.results = data.results || []; + } else { + this.results = this.results.concat(data.results || []); + } + this.total = data.total || 0; + this.searched = true; + } catch (e) { + this.error = "Could not reach the API. Is the server running?"; + } finally { + this.loading = false; + } + }, + + loadMore() { + this.offset += this.limit; + this.doSearch(false); + }, + + canonicalURL(r) { + const h = r.author_handle || ""; + switch (r.record_type) { + case "repo": + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name : "#"; + case "issue": + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/issues" : "#"; + case "pull": + return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/pulls" : "#"; + case "profile": + return h ? "https://tangled.org/" + h : "#"; + default: + return "#"; + } + }, + + relTime(iso) { + if (!iso) return ""; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return mins + "m ago"; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return hrs + "h ago"; + const days = Math.floor(hrs / 24); + if (days < 30) return days + "d ago"; + const months = Math.floor(days / 30); + return months + "mo ago"; + }, + }; +} diff --git a/packages/api/internal/view/static/style.css b/packages/api/internal/view/static/style.css new file mode 100644 index 0000000..4d767c0 --- /dev/null +++ b/packages/api/internal/view/static/style.css @@ -0,0 +1,206 @@ +:root { + --bg: #0e0e0e; + --surface: #1a1a1a; + --border: #2a2a2a; + --text: #e0e0e0; + --text-dim: #888; + --accent: #7aa2f7; + --mark-bg: #7aa2f733; + --mono: "Google Sans Mono", monospace; + --sans: "Google Sans", sans-serif; + --radius: 6px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--sans); + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* Nav */ +.nav { + border-bottom: 1px solid var(--border); + padding: .75rem 1rem; +} +.nav-inner { + max-width: 720px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; +} +.nav-brand { + font-family: var(--mono); + font-weight: 700; + font-size: 1.1rem; + color: var(--text); +} +.nav-brand:hover { text-decoration: none; color: var(--accent); } +.nav-links { display: flex; gap: 1.25rem; font-size: .9rem; } + +/* Main */ +.main { + max-width: 720px; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; + flex: 1; +} + +/* Footer */ +.footer { + border-top: 1px solid var(--border); + padding: 1rem; + font-size: .8rem; + color: var(--text-dim); +} +.footer-inner { max-width: 720px; margin: 0 auto; } + +/* Search hero */ +.search-hero { margin-bottom: 1.5rem; } +.search-hero h1 { font-size: 1.5rem; margin-bottom: 1rem; font-weight: 500; } +.search-form { display: flex; gap: .5rem; margin-bottom: .75rem; } +.search-input { + flex: 1; + padding: .6rem .75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--sans); + font-size: .95rem; + outline: none; +} +.search-input:focus { border-color: var(--accent); } +.search-input::placeholder { color: var(--text-dim); } + +/* Buttons */ +.btn { + padding: .6rem 1.1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--sans); + font-size: .9rem; + cursor: pointer; +} +.btn:hover { border-color: var(--accent); color: var(--accent); } +.btn:disabled { opacity: .5; cursor: default; } +.btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 500; } +.btn-primary:hover { background: #6b93e8; color: var(--bg); } +.btn-more { width: 100%; margin-top: .75rem; } + +/* Filter bar */ +.filter-bar { display: flex; gap: .5rem; flex-wrap: wrap; } +.filter-bar select, +.filter-input { + padding: .45rem .6rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--sans); + font-size: .85rem; + outline: none; +} +.filter-bar select:focus, +.filter-input:focus { border-color: var(--accent); } +.filter-input { width: 140px; } +.filter-input::placeholder { color: var(--text-dim); } + +/* Messages */ +.msg { padding: 1rem; color: var(--text-dim); text-align: center; } +.msg-error { color: #f7768e; } + +/* Result cards */ +.card { + display: block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .85rem 1rem; + margin-bottom: .6rem; + color: var(--text); + transition: border-color .15s; +} +.card:hover { border-color: var(--accent); text-decoration: none; } +.card-head { display: flex; align-items: center; gap: .5rem; margin-bottom: .35rem; } +.badge { + font-family: var(--mono); + font-size: .7rem; + padding: .15rem .45rem; + background: var(--border); + border-radius: 3px; + text-transform: uppercase; + color: var(--text-dim); + white-space: nowrap; +} +.card-title { font-weight: 500; font-size: .95rem; } +.card-snippet { + font-size: .85rem; + color: var(--text-dim); + margin-bottom: .35rem; + line-height: 1.5; +} +.card-snippet mark { + background: var(--mark-bg); + color: var(--accent); + padding: 0 .1rem; + border-radius: 2px; +} +.card-meta { font-size: .78rem; color: var(--text-dim); display: flex; gap: .5rem; } +.meta-sep::before { content: "\00b7"; margin-right: .5rem; } + +/* Docs */ +.main h1 { font-size: 1.4rem; margin-bottom: 1rem; font-weight: 500; } +.main h2 { font-size: 1.1rem; margin: 1.5rem 0 .5rem; font-weight: 500; } +.main h3 { font-size: .95rem; margin: 1rem 0 .4rem; font-weight: 500; } +.main p { margin-bottom: .75rem; } +.main table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; + font-size: .85rem; +} +.main th, .main td { + text-align: left; + padding: .45rem .6rem; + border-bottom: 1px solid var(--border); +} +.main th { color: var(--text-dim); font-weight: 500; } +.main code { + font-family: var(--mono); + font-size: .85em; + background: var(--surface); + padding: .1rem .35rem; + border-radius: 3px; +} +.main pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .85rem 1rem; + overflow-x: auto; + margin-bottom: 1rem; + font-size: .82rem; + line-height: 1.5; +} +.main pre code { background: none; padding: 0; } + +/* Mobile */ +@media (max-width: 640px) { + .search-form { flex-direction: column; } + .filter-bar { flex-direction: column; } + .filter-input { width: 100%; } + .card-meta { flex-wrap: wrap; } +} diff --git a/packages/api/internal/view/templates/docs/documents.html b/packages/api/internal/view/templates/docs/documents.html new file mode 100644 index 0000000..b5eede0 --- /dev/null +++ b/packages/api/internal/view/templates/docs/documents.html @@ -0,0 +1,48 @@ +{{define "title"}}GET /documents/{id} — Twister API{{end}} +{{define "content"}} +

GET /documents/{id}

+

Fetch a single indexed document by its stable ID.

+ +

Path Parameters

+ + + + + +
NameTypeDescription
idstringDocument stable ID (format: did|collection|rkey).
+ +

Example Request

+
curl "https://your-domain/documents/did:plc:abc|sh.tangled.repo|3kb3fge5lm32x"
+ +

Example Response

+
{
+  "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x",
+  "did": "did:plc:abc",
+  "collection": "sh.tangled.repo",
+  "rkey": "3kb3fge5lm32x",
+  "at_uri": "at://did:plc:abc/sh.tangled.repo/3kb3fge5lm32x",
+  "cid": "bafyreig...",
+  "record_type": "repo",
+  "title": "glow-rs",
+  "body": "A TUI markdown viewer inspired by Glow, written in Rust.",
+  "summary": "Rust TUI markdown viewer",
+  "repo_name": "glow-rs",
+  "author_handle": "desertthunder.dev",
+  "tags_json": "[\"rust\", \"tui\", \"markdown\"]",
+  "language": "en",
+  "created_at": "2026-03-20T10:00:00Z",
+  "updated_at": "2026-03-22T15:03:11Z",
+  "indexed_at": "2026-03-22T15:05:00Z"
+}
+ +

Errors

+ + + + + + + +
StatusCondition
400Missing or empty document ID.
404Document not found or has been deleted.
500Database error.
+{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/health.html b/packages/api/internal/view/templates/docs/health.html new file mode 100644 index 0000000..38a651f --- /dev/null +++ b/packages/api/internal/view/templates/docs/health.html @@ -0,0 +1,40 @@ +{{define "title"}}Health Endpoints — Twister API{{end}} +{{define "content"}} +

Health Endpoints

+

Twister exposes two health check endpoints for monitoring and orchestration.

+ +

GET /healthz

+

Liveness probe. Returns 200 if the process is responsive. No dependency checks.

+ +

Example Request

+
curl https://your-domain/healthz
+ +

Response

+
{"status": "ok"}
+

Always returns 200 OK.

+ +

GET /readyz

+

Readiness probe. Checks that the database is reachable by executing a test query.

+ +

Example Request

+
curl https://your-domain/readyz
+ +

Response (healthy)

+
{"status": "ready"}
+

Returns 200 OK when the database is reachable.

+ +

Response (unhealthy)

+
{"error": "db_unreachable", "message": "database is not reachable"}
+

Returns 503 Service Unavailable when the database cannot be reached.

+ +

Usage

+

Configure your orchestrator (Railway, Kubernetes, etc.) to poll these endpoints:

+ + + + + + +
EndpointPurposeFailure Action
/healthzLivenessRestart the process
/readyzReadinessRemove from load balancer
+{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/index.html b/packages/api/internal/view/templates/docs/index.html new file mode 100644 index 0000000..d354f4e --- /dev/null +++ b/packages/api/internal/view/templates/docs/index.html @@ -0,0 +1,34 @@ +{{define "title"}}API Docs — Twister{{end}} +{{define "content"}} +

API Documentation

+

Twister exposes a public JSON API for searching indexed Tangled content. No authentication is required for read endpoints.

+ +

Base URL

+
https://<your-twister-domain>
+

All endpoints are relative to the base URL. When using the search site, the API is on the same origin.

+ +

Response Shape

+

All responses are JSON. Successful responses return the resource directly. Errors return:

+
{
+  "error": "error_code",
+  "message": "Human-readable description"
+}
+ +

Endpoints

+ + + + + + + + +
MethodPathDescription
GET/searchSearch indexed documents
GET/documents/{id}Fetch a single document
GET/healthzLiveness probe
GET/readyzReadiness probe
+ +

Pagination

+

List endpoints use limit and offset query parameters. The default limit is 20, maximum is 100.

+ +

Filtering

+

Search supports filters via query parameters: collection, type, author, repo, language, state, from, to. See the search endpoint docs for details.

+{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/docs/search.html b/packages/api/internal/view/templates/docs/search.html new file mode 100644 index 0000000..55f08fa --- /dev/null +++ b/packages/api/internal/view/templates/docs/search.html @@ -0,0 +1,74 @@ +{{define "title"}}GET /search — Twister API{{end}} +{{define "content"}} +

GET /search

+

Search indexed Tangled documents with keyword matching. Returns a paginated list of results with highlighted snippets.

+ +

Parameters

+ + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
qstringrequiredSearch query. Supports boolean operators, phrases, and prefix matching.
modestringkeywordSearch mode: keyword, semantic, or hybrid.
limitint20Results per page (1–100).
offsetint0Pagination offset.
collectionstring—Filter by ATProto collection NSID.
typestring—Filter by record type: repo, issue, pull, profile, string.
authorstring—Filter by author handle or DID.
repostring—Filter by repo name.
languagestring—Filter by programming language.
statestring—Filter by state: open, closed, merged.
fromstring—Created after (ISO 8601).
tostring—Created before (ISO 8601).
+ +

Example Request

+
curl "https://your-domain/search?q=rust+tui&type=repo&limit=5"
+ +

Example Response

+
{
+  "query": "rust tui",
+  "mode": "keyword",
+  "total": 12,
+  "limit": 5,
+  "offset": 0,
+  "results": [
+    {
+      "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x",
+      "collection": "sh.tangled.repo",
+      "record_type": "repo",
+      "title": "glow-rs",
+      "body_snippet": "A <mark>TUI</mark> markdown viewer written in <mark>Rust</mark>...",
+      "summary": "Rust TUI markdown viewer",
+      "repo_name": "glow-rs",
+      "author_handle": "desertthunder.dev",
+      "score": 0.842,
+      "created_at": "2026-03-20T10:00:00Z",
+      "updated_at": "2026-03-22T15:03:11Z"
+    }
+  ]
+}
+ +

Query Syntax

+

The keyword search uses Tantivy query syntax:

+ + + + + + + + + +
FeatureExample
Boolean ANDgo AND search
Boolean NOTrust NOT unsafe
Phrase"pull request"
Prefixtang*
Field-scopedtitle:parser
+ +

Errors

+ + + + + + +
StatusCondition
400Missing q, invalid limit/offset, unknown parameters.
500Internal search failure.
+{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/index.html b/packages/api/internal/view/templates/index.html new file mode 100644 index 0000000..b9973d9 --- /dev/null +++ b/packages/api/internal/view/templates/index.html @@ -0,0 +1,64 @@ +{{define "title"}}Twister — Search Tangled{{end}} +{{define "head"}} + +{{end}} +{{define "content"}} +
+
+

Search Tangled

+
+ + +
+
+ + + + +
+
+ +
+ + + + +
+
+{{end}} +{{define "scripts"}} + +{{end}} +{{template "layout" .}} diff --git a/packages/api/internal/view/templates/layout.html b/packages/api/internal/view/templates/layout.html new file mode 100644 index 0000000..72012b9 --- /dev/null +++ b/packages/api/internal/view/templates/layout.html @@ -0,0 +1,33 @@ +{{define "layout"}} + + + + + {{block "title" .}}Twister{{end}} + + + + + {{block "head" .}}{{end}} + + + +
+ {{block "content" .}}{{end}} +
+ + {{block "scripts" .}}{{end}} + +{{end}} diff --git a/packages/api/internal/view/view.go b/packages/api/internal/view/view.go new file mode 100644 index 0000000..b26414f --- /dev/null +++ b/packages/api/internal/view/view.go @@ -0,0 +1,49 @@ +package view + +import ( + "embed" + "html/template" + "io/fs" + "net/http" +) + +//go:embed templates static +var content embed.FS + +var templates *template.Template + +func init() { + templates = template.Must(template.ParseFS(content, + "templates/layout.html", + "templates/index.html", + "templates/docs/index.html", + "templates/docs/search.html", + "templates/docs/documents.html", + "templates/docs/health.html", + )) +} + +// Handler returns an http.Handler that serves the site pages and static assets. +func Handler() http.Handler { + mux := http.NewServeMux() + + staticFS, _ := fs.Sub(content, "static") + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS))) + + mux.HandleFunc("GET /docs/search", renderPage("docs/search.html")) + mux.HandleFunc("GET /docs/documents", renderPage("docs/documents.html")) + mux.HandleFunc("GET /docs/health", renderPage("docs/health.html")) + mux.HandleFunc("GET /docs", renderPage("docs/index.html")) + mux.HandleFunc("GET /{$}", renderPage("index.html")) + + return mux +} + +func renderPage(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := templates.ExecuteTemplate(w, name, nil); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + } + } +}