summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:39:31 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:39:31 +0200
commit4de7f7dca7485c9a6c94f6f86a53d01a974fce54 (patch)
treebcdc1ea366a068310e460804c4a5f04df3ae20f4
parentb72b81c410c161797eb525a6231b0517b4def993 (diff)
downloadlectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.tar.gz
lectio-4de7f7dca7485c9a6c94f6f86a53d01a974fce54.zip
web: HTMX server + lectio-web binary
NewServer wires B1's RenderReadings/Themes/themeCSS/embedded static+ templates FS into an http.ServeMux: GET / (full page), GET /readings (HTMX reading-pane fragment), GET /lookup (bible.Lookup passage search fragment), GET /theme.css (theme stylesheet, falling back through cfg.WebTheme to the built-in default on an unknown name), GET /static/. Run listens on cfg.WebPort (0 = OS-picked free port), prints the URL, best-effort opens a browser, then serves. cmd/lectio-web is the binary entry point (config.Load -> web.Run). Fold-in from the B1 review: hardened themeCSS's name guard to an explicit ^[A-Za-z0-9_-]+$ allowlist (the old filepath.Base/ContainsAny check let ".." through), plus guard-rejection and HTML-escaping regression tests -- B2 is what makes /theme.css?name=<raw> reachable from the network, so it owns closing this out.
-rw-r--r--cmd/lectio-web/main.go21
-rw-r--r--internal/web/render.go15
-rw-r--r--internal/web/render_test.go24
-rw-r--r--internal/web/server.go322
-rw-r--r--internal/web/server_test.go107
-rw-r--r--internal/web/templates/index.html72
-rw-r--r--internal/web/templates/lookup.html20
7 files changed, 577 insertions, 4 deletions
diff --git a/cmd/lectio-web/main.go b/cmd/lectio-web/main.go
new file mode 100644
index 0000000..44845b8
--- /dev/null
+++ b/cmd/lectio-web/main.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/web"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if err := web.Run(cfg); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/internal/web/render.go b/internal/web/render.go
index cf72d41..67c7a37 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -130,14 +130,21 @@ func Themes() []string {
return names
}
+// themeNameRe is the allowlist a theme name must match: letters, digits,
+// underscore, hyphen only. Legitimate theme stems (built-in or user) already
+// fit this shape; it is deliberately stricter than "no path separators" so
+// it rejects "." / ".." / any other filesystem metacharacter outright
+// instead of trying to enumerate what's unsafe.
+var themeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
+
// themeCSS returns one theme's CSS: the user file
// ${XDG_CONFIG_HOME:-~/.config}/lectio/themes/<name>.css if it exists,
// otherwise the embedded static/themes/<name>.css, otherwise an error.
-// name must be a bare file stem (no path separators or "..") -- callers
-// (B2's /theme.css?name= handler) pass this straight through from an HTTP
-// query parameter, so this rejects path traversal rather than trusting it.
+// name must match themeNameRe -- callers (B2's /theme.css?name= handler)
+// pass this straight through from an HTTP query parameter, so this rejects
+// path traversal rather than trusting it.
func themeCSS(name string) ([]byte, error) {
- if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
+ if !themeNameRe.MatchString(name) {
return nil, fmt.Errorf("web: invalid theme name %q", name)
}
diff --git a/internal/web/render_test.go b/internal/web/render_test.go
index c42e1b1..87ee62d 100644
--- a/internal/web/render_test.go
+++ b/internal/web/render_test.go
@@ -30,6 +30,30 @@ func TestBuiltinThemes(t *testing.T) {
}
}
+func TestThemeCSSGuardRejectsInvalidNames(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no user themes
+ for _, name := range []string{"../../etc/passwd", "..", "a/b", ""} {
+ if _, err := themeCSS(name); err == nil {
+ t.Errorf("themeCSS(%q): expected error, got nil", name)
+ }
+ }
+}
+
+func TestRenderReadingsEscapesScriptText(t *testing.T) {
+ secs := []liturgy.Section{{
+ Heading: "Test",
+ PartID: "pierwsze_czytanie",
+ Paragraphs: [][]string{{"<script>alert(1)</script>"}},
+ }}
+ html := string(RenderReadings(secs, []string{"pl"}, "new"))
+ if strings.Contains(html, "<script>alert(1)</script>") {
+ t.Errorf("raw <script> leaked into rendered output: %q", html)
+ }
+ if !strings.Contains(html, "&lt;script&gt;alert(1)&lt;/script&gt;") {
+ t.Errorf("expected escaped script tag in output: %q", html)
+ }
+}
+
func TestUserTheme(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..b1a483f
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,322 @@
+// This file wires the package's render helpers (RenderReadings, Themes,
+// themeCSS, the embedded static/templates FS) into an HTTP server: the full
+// page (GET /), the HTMX reading-pane partial (GET /readings), a passage
+// lookup partial (GET /lookup), a theme stylesheet endpoint (GET
+// /theme.css) and the embedded static assets (GET /static/...).
+package web
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "net"
+ "net/http"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
+ "github.com/lukaszkasprzak/lectio/internal/readings"
+ "github.com/lukaszkasprzak/lectio/internal/render"
+)
+
+// bibleVersions is the fixed, Themes-independent list of scripture versions
+// the web UI's checkboxes offer -- independent of any one cfg.Versions, so
+// every visitor sees the same five choices regardless of their config file.
+var bibleVersions = []string{"pl", "wuj", "vul", "grb", "drb"}
+
+// NewServer builds lectio-web's route tree. cfg supplies the defaults
+// (lectionary, versions, theme, offline) that requests can override via
+// query parameters; it is never mutated.
+func NewServer(cfg config.Config) http.Handler {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("GET /{$}", indexHandler(cfg))
+ mux.HandleFunc("GET /readings", readingsHandler(cfg))
+ mux.HandleFunc("GET /lookup", lookupHandler(cfg))
+ mux.HandleFunc("GET /theme.css", themeCSSHandler(cfg))
+
+ staticSub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ // Unreachable: "static" is embedded at build time by render.go.
+ panic(err)
+ }
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
+
+ return mux
+}
+
+// today is lectio-web's "no ?date=" default, in the YYYY-MM-DD form every
+// other package's Date fields expect.
+func today() string {
+ return time.Now().Format("2006-01-02")
+}
+
+// shiftDate adds days to date (YYYY-MM-DD); an unparsable date is returned
+// unchanged, mirroring internal/tui's shiftDate.
+func shiftDate(date string, days int) string {
+ t, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ return date
+ }
+ return t.AddDate(0, 0, days).Format("2006-01-02")
+}
+
+// requestVersions returns the versions requested via one or more repeated
+// ?v= query params, falling back to cfg.Versions when none were given.
+func requestVersions(cfg config.Config, r *http.Request) []string {
+ if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 {
+ return vs
+ }
+ return append([]string(nil), cfg.Versions...)
+}
+
+// queryBool reads a truthy/falsy query param ("1"/"true"/"on"/"yes" vs.
+// "0"/"false"/"off"/"no"), falling back to def when the param is absent or
+// unrecognized.
+func queryBool(r *http.Request, name string, def bool) bool {
+ if !r.URL.Query().Has(name) {
+ return def
+ }
+ switch strings.ToLower(r.URL.Query().Get(name)) {
+ case "1", "true", "on", "yes":
+ return true
+ case "0", "false", "off", "no":
+ return false
+ default:
+ return def
+ }
+}
+
+// requestLectionary returns the ?lectionary= override, falling back to
+// cfg.Lectionary.
+func requestLectionary(cfg config.Config, r *http.Request) string {
+ if l := r.URL.Query().Get("lectionary"); l != "" {
+ return l
+ }
+ return cfg.Lectionary
+}
+
+// loadSections runs the readings router for one request: date/lectionary
+// override cfg, all controls part filtering, and (when cfg.Offline)
+// versions is swapped via render.OfflineVersions before being handed back
+// to the caller for rendering -- so the caller's column labels always match
+// what was actually loadable.
+func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) ([]liturgy.Section, []string, error) {
+ cfg.Lectionary = lectionary
+ if cfg.Offline {
+ versions = render.OfflineVersions(versions)
+ }
+ secs, err := readings.Load(cfg, readings.Options{
+ Date: date,
+ Offline: cfg.Offline,
+ All: all,
+ })
+ return secs, versions, err
+}
+
+// indexData is what templates/index.html ranges/branches over.
+type indexData struct {
+ Date, PrevDate, NextDate string
+ Lectionary string
+ VersionOpts []versionOpt
+ All bool
+ ThemeOpts []themeOpt
+ Theme string
+ Ref string
+ Lookup template.HTML
+ Reading template.HTML
+}
+
+type versionOpt struct {
+ Code string
+ Checked bool
+}
+
+type themeOpt struct {
+ Name string
+ Selected bool
+}
+
+// indexHandler serves the full page: controls reflecting the request's
+// query (defaulting from cfg) plus the reading pane pre-rendered for that
+// same query, so a plain (JS-less) GET / already shows today's gospel.
+func indexHandler(cfg config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ date := r.URL.Query().Get("date")
+ if date == "" {
+ date = today()
+ }
+ lectionary := requestLectionary(cfg, r)
+ all := queryBool(r, "all", cfg.All)
+ versions := requestVersions(cfg, r)
+ theme := r.URL.Query().Get("theme")
+ if theme == "" {
+ theme = cfg.WebTheme
+ }
+
+ secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
+ reading := renderOrError(secs, loadVersions, lectionary, err)
+
+ selected := map[string]bool{}
+ for _, v := range versions {
+ selected[v] = true
+ }
+ versionOpts := make([]versionOpt, 0, len(bibleVersions))
+ for _, v := range bibleVersions {
+ versionOpts = append(versionOpts, versionOpt{Code: v, Checked: selected[v]})
+ }
+
+ themes := Themes()
+ themeOpts := make([]themeOpt, 0, len(themes))
+ for _, name := range themes {
+ themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme})
+ }
+
+ data := indexData{
+ Date: date,
+ PrevDate: shiftDate(date, -1),
+ NextDate: shiftDate(date, 1),
+ Lectionary: lectionary,
+ VersionOpts: versionOpts,
+ All: all,
+ ThemeOpts: themeOpts,
+ Theme: theme,
+ Ref: r.URL.Query().Get("ref"), // echoed into the lookup box on a shared/bookmarked link
+ Reading: reading,
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+// readingsHandler serves the HTMX reading-pane partial: a fragment only,
+// produced entirely by RenderReadings (never re-implemented here).
+func readingsHandler(cfg config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ date := r.URL.Query().Get("date")
+ if date == "" {
+ date = today()
+ }
+ lectionary := requestLectionary(cfg, r)
+ all := queryBool(r, "all", cfg.All)
+ versions := requestVersions(cfg, r)
+
+ secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
+ reading := renderOrError(secs, loadVersions, lectionary, err)
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ io.WriteString(w, string(reading))
+ }
+}
+
+// renderOrError returns RenderReadings' fragment, or (on a readings.Load
+// error) a small escaped error paragraph -- readings.Load errors are
+// expected in normal operation (an unpublished date, no network while
+// online, ...) so the pane should show them, not 500.
+func renderOrError(secs []liturgy.Section, versions []string, lectionary string, err error) template.HTML {
+ if err != nil {
+ return template.HTML(`<p class="error">` + template.HTMLEscapeString(err.Error()) + `</p>`)
+ }
+ if len(secs) == 0 {
+ return template.HTML(`<p class="error">brak czytań na ten dzień</p>`)
+ }
+ return RenderReadings(secs, versions, lectionary)
+}
+
+// lookupColumn is what templates/lookup.html ranges over: one per requested
+// version.
+type lookupColumn struct {
+ Version string
+ Verses []bible.Verse
+ Missing []string
+}
+
+// lookupHandler serves the passage-lookup fragment: ref is looked up as-is
+// (already English/kjv-style, e.g. "J 20:1") against each requested version
+// via bible.Lookup directly -- no Polish->English conversion, that only
+// applies to a section's own citation (render.GatherVersion), not a
+// free-typed lookup.
+func lookupHandler(cfg config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ref := strings.TrimSpace(r.URL.Query().Get("ref"))
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if ref == "" {
+ return
+ }
+
+ versions := requestVersions(cfg, r)
+ cols := make([]lookupColumn, 0, len(versions))
+ for _, v := range versions {
+ verses, missing := bible.Lookup(v, ref)
+ cols = append(cols, lookupColumn{Version: v, Verses: verses, Missing: missing})
+ }
+
+ if err := tmpl.ExecuteTemplate(w, "lookup.html", cols); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+// themeCSSHandler serves one theme's stylesheet: the requested name, or
+// (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in
+// default -- so an unrecognized ?name= degrades to a working theme instead
+// of a broken page.
+func themeCSSHandler(cfg config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ name := r.URL.Query().Get("name")
+ css, err := themeCSS(name)
+ if err != nil {
+ css, err = themeCSS(cfg.WebTheme)
+ }
+ if err != nil {
+ css, err = themeCSS(config.Default().WebTheme)
+ }
+ if err != nil {
+ http.Error(w, "web: no theme available", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Write(css)
+ }
+}
+
+// Run starts lectio-web: listens on cfg.WebPort (0 picks a free OS port),
+// prints the URL, best-effort opens it in a browser, and serves until the
+// listener errors.
+func Run(cfg config.Config) error {
+ ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort))
+ if err != nil {
+ return err
+ }
+ port := ln.Addr().(*net.TCPAddr).Port
+ url := fmt.Sprintf("http://localhost:%d", port)
+ fmt.Println("lectio-web on " + url)
+
+ openBrowser(url) // best-effort; ignore failure (no browser, headless, ...)
+
+ return http.Serve(ln, NewServer(cfg))
+}
+
+// openBrowser best-effort launches the platform's "open a URL" command;
+// any failure (missing command, no display, ...) is silently ignored, as
+// lectio-web is fully usable by just visiting the printed URL manually.
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("cmd", "/c", "start", "", url)
+ default:
+ cmd = exec.Command("xdg-open", url)
+ }
+ _ = cmd.Start()
+}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
new file mode 100644
index 0000000..9e37177
--- /dev/null
+++ b/internal/web/server_test.go
@@ -0,0 +1,107 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
+)
+
+// TestServer exercises NewServer's handler tree end to end via httptest,
+// against the same fixture HTML/hook internal/readings uses (see
+// readings_test.go TestLoadModernRoutes): no real network, no real browser.
+func TestServer(t *testing.T) {
+ html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write(html)
+ }))
+ defer fixtureServer.Close()
+ liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia")
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ srv := NewServer(config.Default())
+
+ t.Run("index page", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Ewangelia") {
+ t.Errorf("body missing reading heading: %q", body)
+ }
+ if !strings.Contains(body, "htmx") {
+ t.Errorf("body missing htmx reference")
+ }
+ if !strings.Contains(body, `id="theme"`) {
+ t.Errorf("body missing theme <link>")
+ }
+ })
+
+ t.Run("readings partial", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&all=1", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if strings.Contains(body, "<html") {
+ t.Errorf("partial is not a fragment: %q", body)
+ }
+ if !strings.Contains(body, "Ewangelia") {
+ t.Errorf("partial missing reading heading: %q", body)
+ }
+ })
+
+ t.Run("lookup", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/lookup?ref=J+20:1&v=wuj", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "20:1") {
+ t.Errorf("lookup result missing verse: %q", rec.Body.String())
+ }
+ })
+
+ t.Run("theme.css", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=benedictines", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Header().Get("Content-Type"), "css") {
+ t.Errorf("Content-Type = %q, want it to contain css", rec.Header().Get("Content-Type"))
+ }
+ })
+
+ t.Run("static", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/static/htmx.min.js", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if rec.Body.Len() == 0 {
+ t.Error("static/htmx.min.js served empty body")
+ }
+ })
+
+ t.Run("theme.css unknown falls back", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/theme.css?name=does-not-exist", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (fallback to cfg.WebTheme/default)", rec.Code)
+ }
+ if rec.Body.Len() == 0 {
+ t.Error("theme.css fallback served empty body")
+ }
+ })
+}
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
new file mode 100644
index 0000000..4dd580f
--- /dev/null
+++ b/internal/web/templates/index.html
@@ -0,0 +1,72 @@
+{{/* index.html renders the full lectio-web page: top-bar controls (date,
+ lectionary, version checkboxes, all/gospel toggle, theme select), a
+ passage-lookup form, and the pre-rendered reading pane (#pane).
+
+ The controls form (#controls) carries hx-get="/readings" and fires on
+ any "change" bubbling up from its fields (htmx auto-includes an
+ hx-get element's enclosing form fields as the request's query
+ params -- no hx-include needed). The date +/- buttons are separate
+ hx-get elements nested in the same form so their own field values are
+ included too; they override "date" via hx-vals. The theme <select>
+ has no server round trip: it swaps the #theme <link>'s href directly. */}}
+<!doctype html>
+<html lang="pl">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>lectio</title>
+<link rel="stylesheet" href="/static/base.css">
+<link id="theme" rel="stylesheet" href="/theme.css?name={{.Theme}}">
+<script src="/static/htmx.min.js"></script>
+</head>
+<body>
+<div class="page">
+
+ <form id="controls" class="controls" hx-get="/readings" hx-target="#pane" hx-trigger="change">
+ <span class="date-nav">
+ <button type="button" hx-get="/readings" hx-target="#pane"
+ hx-vals='{"date":"{{.PrevDate}}"}'>&larr;</button>
+ <input type="date" name="date" value="{{.Date}}">
+ <button type="button" hx-get="/readings" hx-target="#pane"
+ hx-vals='{"date":"{{.NextDate}}"}'>&rarr;</button>
+ </span>
+
+ <label>lekcjonarz
+ <select name="lectionary">
+ <option value="new" {{if eq .Lectionary "new"}}selected{{end}}>nowy</option>
+ <option value="traditional" {{if eq .Lectionary "traditional"}}selected{{end}}>tradycyjny</option>
+ </select>
+ </label>
+
+ {{range .VersionOpts}}
+ <label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>
+ {{end}}
+
+ <label>zakres
+ <select name="all">
+ <option value="0" {{if not .All}}selected{{end}}>Ewangelia</option>
+ <option value="1" {{if .All}}selected{{end}}>wszystkie części</option>
+ </select>
+ </label>
+ </form>
+
+ <label class="theme-picker">motyw
+ <select id="theme-select"
+ onchange="document.getElementById('theme').href='/theme.css?name='+encodeURIComponent(this.value)">
+ {{range .ThemeOpts}}
+ <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option>
+ {{end}}
+ </select>
+ </label>
+
+ <form id="lookup-form" class="controls" hx-get="/lookup" hx-target="#lookup-results" hx-include="#controls">
+ <input type="text" name="ref" placeholder="np. J 20:1" value="{{.Ref}}">
+ <button type="submit">szukaj</button>
+ </form>
+ <div id="lookup-results">{{.Lookup}}</div>
+
+ <div id="pane">{{.Reading}}</div>
+
+</div>
+</body>
+</html>
diff --git a/internal/web/templates/lookup.html b/internal/web/templates/lookup.html
new file mode 100644
index 0000000..c8bc2a7
--- /dev/null
+++ b/internal/web/templates/lookup.html
@@ -0,0 +1,20 @@
+{{/* lookup.html renders the passage-lookup fragment: one .column per
+ requested version, each version's matched bible.Verse rows plus any
+ sub-refs the corpus had no entry for. Structurally mirrors
+ readings.html's .columns/.column/.block classes so theme CSS restyles
+ both panes the same way. */}}
+{{if .}}
+<div class="columns">
+ {{range .}}
+ <div class="column">
+ <h3 class="version-label">{{.Version}}</h3>
+ {{range .Verses}}
+ <p class="block verse"><span class="vnum">{{.Chapter}}:{{.Verse}}</span> {{.Text}}</p>
+ {{end}}
+ {{if .Missing}}
+ <p class="block error">brak: {{range $i, $m := .Missing}}{{if $i}}, {{end}}{{$m}}{{end}}</p>
+ {{end}}
+ </div>
+ {{end}}
+</div>
+{{end}}