diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 15:00:17 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 15:00:17 +0200 |
| commit | aa6c9dad764089a3f0e287b5f90937e97dde3921 (patch) | |
| tree | 213f7f56bf3c5ca1db82f0eee7c482886fa9ce43 /internal | |
| parent | 66a17fda33e44e9022d6724f850323f754715259 (diff) | |
| download | lectio-aa6c9dad764089a3f0e287b5f90937e97dde3921.tar.gz lectio-aa6c9dad764089a3f0e287b5f90937e97dde3921.zip | |
web: horizontal/vertical/interlinear display modes + web_display config
Adds a display-layout option to lectio-web alongside the existing colour
themes:
- config: WebDisplay field (toml web_display), default "horizontal",
normalized on load via the new exported config.NormalizeDisplay
(unknown -> "horizontal", same lenient style as the other web_* fields).
- render: extracts the citation/ToEnglishRef resolution shared by
GatherVersion into an unexported resolveRef helper (GatherVersion's
signature/behavior unchanged) and adds GatherVerses(version, sec,
lectionary) returning raw bible.Verse structs plus a versified flag, for
column/interlinear alignment.
- web/render: RenderReadings gains a display parameter. "horizontal" is
the original stacked layout, byte-for-byte the same code path as
before. "vertical" reuses the same per-version column data in a
.display-vertical/.vcol grid (the CLI compare view, browser-side).
"interlinear" maps versions through render.OfflineVersions' pl->wuj
substitution (pl has no verse numbers), gathers GatherVerses per
version, and interleaves them by chapter:verse into .ilverse/.illine
blocks (ordered union of keys, first versified version's order first).
- server: adds a display <select> to the top bar wired into the existing
#controls HTMX form; a resolveQuery(cfg, r) helper replaces the
duplicated date/lectionary/all/versions(+now display) resolution in
indexHandler and readingsHandler.
- fold-in from the B2 review: indexHandler now populates indexData.Lookup
via a shared renderLookup(ref, versions) helper (also used by
lookupHandler), so a bookmarked "/?ref=...&v=..." link shows its
lookup result instead of an empty pane.
- base.css: layout-only rules for the two new modes (no colours; themes
stay colour-only).
go test ./..., go vet ./..., gofmt clean; go build ./cmd/lectio-web ok.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 24 | ||||
| -rw-r--r-- | internal/config/config.toml | 1 | ||||
| -rw-r--r-- | internal/config/config_test.go | 40 | ||||
| -rw-r--r-- | internal/render/render.go | 69 | ||||
| -rw-r--r-- | internal/render/render_test.go | 31 | ||||
| -rw-r--r-- | internal/web/render.go | 160 | ||||
| -rw-r--r-- | internal/web/render_test.go | 58 | ||||
| -rw-r--r-- | internal/web/server.go | 110 | ||||
| -rw-r--r-- | internal/web/server_test.go | 91 | ||||
| -rw-r--r-- | internal/web/static/base.css | 48 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 16 | ||||
| -rw-r--r-- | internal/web/templates/readings-interlinear.html | 29 | ||||
| -rw-r--r-- | internal/web/templates/readings-vertical.html | 29 |
13 files changed, 632 insertions, 74 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 9c3c3cd..c15b5a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/pelletier/go-toml/v2" ) @@ -30,6 +31,26 @@ var validVersions = map[string]bool{ "drb": true, } +// validDisplays are the lectio-web reading-pane layouts. +var validDisplays = map[string]bool{ + "horizontal": true, + "vertical": true, + "interlinear": true, +} + +// NormalizeDisplay lower-cases display and falls back to "horizontal" when +// it is not one of validDisplays -- the same lenient style as the rest of +// lectio-web's config fields (no error, just a safe default). Exported so +// internal/web can apply the identical normalization to an explicit +// ?display= query override, keeping the valid set in this one place. +func NormalizeDisplay(display string) string { + d := strings.ToLower(display) + if !validDisplays[d] { + return "horizontal" + } + return d +} + // Config holds lectio's user-configurable settings. type Config struct { SchemaVersion int `toml:"schema_version"` @@ -42,6 +63,7 @@ type Config struct { Offline bool `toml:"offline"` WebTheme string `toml:"web_theme"` WebPort int `toml:"web_port"` + WebDisplay string `toml:"web_display"` Parts map[string]map[string]bool `toml:"parts"` } @@ -69,6 +91,7 @@ func Default() Config { Offline: false, WebTheme: "transfiguration", WebPort: 0, + WebDisplay: "horizontal", Parts: nil, } } @@ -134,6 +157,7 @@ func Load() (Config, error) { fmt.Fprintf(os.Stderr, "lectio: warning: invalid config at %s: %v; using defaults\n", path, err) return def, nil } + cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) if err := validate(cfg); err != nil { return Config{}, err diff --git a/internal/config/config.toml b/internal/config/config.toml index 4ad3289..9c379e7 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -8,6 +8,7 @@ all = false # default to all parts (true) or just the gospel (fa offline = false # true = never fetch; read only harvested sigla + cache web_theme = "transfiguration" # built-in order/season theme or a user theme in ~/.config/lectio/themes/ web_port = 0 # lectio-web port; 0 = auto-pick a free port +web_display = "horizontal" # lectio-web layout: "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse) # Which parts to show. Both tables are commented out -> every part is shown. # Uncomment a table and set a part to false to hide it; parts you don't list diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9b72bc7..9ca311b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,6 +59,46 @@ func TestWebLoadsFromSeed(t *testing.T) { if cfg.WebPort != 0 { t.Errorf("WebPort from seed wrong: got %d, want 0", cfg.WebPort) } + if cfg.WebDisplay != "horizontal" { + t.Errorf("WebDisplay from seed wrong: got %q, want %q", cfg.WebDisplay, "horizontal") + } +} + +func TestWebDisplayDefault(t *testing.T) { + def := Default() + if def.WebDisplay != "horizontal" { + t.Errorf("WebDisplay default wrong: got %q, want %q", def.WebDisplay, "horizontal") + } +} + +func TestWebDisplayLoadsSetting(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.MkdirAll(filepath.Join(dir, "lectio"), 0o755) + os.WriteFile(filepath.Join(dir, "lectio", "config.toml"), + []byte("web_display = \"vertical\"\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WebDisplay != "vertical" { + t.Errorf("WebDisplay = %q, want %q", cfg.WebDisplay, "vertical") + } +} + +func TestWebDisplayNormalizesUnknown(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.MkdirAll(filepath.Join(dir, "lectio"), 0o755) + os.WriteFile(filepath.Join(dir, "lectio", "config.toml"), + []byte("web_display = \"bogus\"\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WebDisplay != "horizontal" { + t.Errorf("WebDisplay = %q, want %q (normalized from bogus)", cfg.WebDisplay, "horizontal") + } } func TestPartShown(t *testing.T) { diff --git a/internal/render/render.go b/internal/render/render.go index 2807272..b8d778c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -55,23 +55,9 @@ func GatherVersion(version string, sec liturgy.Section, lectionary string) (labe return label, gatherPL(sec) } - citation := sec.Citation - if citation == "" { - if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { - citation = c - } - } - if citation == "" { - return label, []string{"(brak odwołania)"} - } - - ref := citation - if lectionary == "new" { - r, err := bible.ToEnglishRef(citation, system(version)) - if err != nil { - return label, []string{fmt.Sprintf("(brak odwołania: %v)", err)} - } - ref = r + ref, err := resolveRef(version, sec, lectionary) + if err != nil { + return label, []string{err.Error()} } verses, missing := bible.Lookup(version, ref) @@ -89,6 +75,55 @@ func GatherVersion(version string, sec liturgy.Section, lectionary string) (labe return label, blocks } +// resolveRef resolves a non-"pl" version's bible.Lookup reference for sec: +// sec.Citation (or, failing that, the citation extracted from sec.Heading), +// converted to English/kjv-style via bible.ToEnglishRef when +// lectionary=="new" (a "traditional" citation is already English-style and +// used as-is). Shared by GatherVersion and GatherVerses so both apply the +// exact same resolution. +func resolveRef(version string, sec liturgy.Section, lectionary string) (string, error) { + citation := sec.Citation + if citation == "" { + if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { + citation = c + } + } + if citation == "" { + return "", fmt.Errorf("(brak odwołania)") + } + + if lectionary != "new" { + return citation, nil + } + ref, err := bible.ToEnglishRef(citation, system(version)) + if err != nil { + return "", fmt.Errorf("(brak odwołania: %w)", err) + } + return ref, nil +} + +// GatherVerses returns one version's verses for a section as raw bible.Verse +// structs (for column/interlinear alignment). versified is false for "pl" +// (paragraph text, no verse numbers) and on any resolution/lookup failure -- +// callers fall back to GatherVersion's string blocks for those. +func GatherVerses(version string, sec liturgy.Section, lectionary string) (label string, verses []bible.Verse, versified bool) { + label = versionLabels[version] + if version == "pl" { + return label, nil, false + } + + ref, err := resolveRef(version, sec, lectionary) + if err != nil { + return label, nil, false + } + + verses, _ = bible.Lookup(version, ref) + if len(verses) == 0 { + return label, nil, false + } + return label, verses, true +} + // gatherPL returns the section's paragraphs, one block per paragraph, with // the liturgical incipit ("Słowa Ewangelii według ...") dropped and repeated // blocks (a responsorial psalm's refrain) deduped to their first occurrence. diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 87f9ed2..1b338a0 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -51,3 +51,34 @@ func TestOfflineVersions(t *testing.T) { } } } + +func TestGatherVersesBible(t *testing.T) { + sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} + label, verses, versified := GatherVerses("wuj", sec, "new") + if !strings.Contains(label, "Wujek") { + t.Errorf("label = %q", label) + } + if !versified { + t.Error("versified = false, want true for wuj with a resolvable citation") + } + if len(verses) == 0 { + t.Fatal("verses empty") + } + if verses[0].Chapter != 20 || verses[0].Verse != 1 || verses[0].Text == "" { + t.Errorf("verses[0] = %+v", verses[0]) + } +} + +func TestGatherVersesPL(t *testing.T) { + sec := liturgy.Section{ + Heading: "Psalm (Ps 1)", + Paragraphs: [][]string{{"stanza one"}}, + } + _, verses, versified := GatherVerses("pl", sec, "new") + if versified { + t.Error("versified = true, want false for pl (paragraph text, no verse numbers)") + } + if verses != nil { + t.Errorf("verses = %+v, want nil for pl", verses) + } +} diff --git a/internal/web/render.go b/internal/web/render.go index 67c7a37..63c1da4 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -18,6 +18,7 @@ import ( "sort" "strings" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/render" ) @@ -59,13 +60,70 @@ type blockView struct { Refrain bool } -// RenderReadings builds the reading pane: for each section, a heading (and -// subtitle if present) followed by one column per version, each built by -// calling render.GatherVersion(v, sec, lectionary). Heading, citation -// (subtitle), verse-number and refrain text are wrapped in -// class="heading|citation|vnum|refrain" spans so theme CSS can restyle -// them; verse/paragraph text is escaped by html/template. -func RenderReadings(secs []liturgy.Section, versions []string, lectionary string) template.HTML { +// ilSectionView, ilVerseView and ilLineView are what +// templates/readings-interlinear.html ranges over: one ilSectionView per +// liturgy.Section, one ilVerseView per chapter:verse key (in ordered-union +// order, see buildInterlinearViews), one ilLineView per version that carries +// that verse. Note holds a short escaped message in place of Verses when no +// requested version could be interleaved for the section. +type ilSectionView struct { + Heading, Subtitle, PartID string + Verses []ilVerseView + Note string +} + +type ilVerseView struct { + VNum string + Lines []ilLineView +} + +type ilLineView struct { + Label, Text string +} + +// RenderReadings builds the reading pane fragment for one of three layouts: +// +// - "horizontal" (or anything unrecognized): the original stacked layout, +// one column per version rendered under a shared heading, unchanged. +// - "vertical": the same per-version columns side by side in a +// ".display-vertical" grid, like the CLI `compare` view. +// - "interlinear": versions interleaved verse-by-verse by chapter:verse +// (see buildInterlinearViews); "pl" cannot participate (no verse +// numbers) and is substituted/dropped via render.OfflineVersions' +// pl->wuj transform before gathering. +// +// In every mode, heading, citation (subtitle), verse-number and refrain +// text are wrapped in class="heading|citation|vnum|refrain|version-label" +// spans so theme CSS can restyle them; verse/paragraph text is escaped by +// html/template. +func RenderReadings(secs []liturgy.Section, versions []string, lectionary, display string) template.HTML { + switch display { + case "vertical": + return renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary)) + case "interlinear": + return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary)) + default: + return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary)) + } +} + +// renderTemplate executes the named embedded template with data, degrading +// to a visible, escaped error paragraph on failure (should be unreachable: +// the templates are embedded and fixed at build time) rather than panicking +// a request handler in the caller. +func renderTemplate(name string, data any) template.HTML { + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { + return template.HTML("<p class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</p>") + } + return template.HTML(buf.String()) +} + +// buildColumnViews gathers each section's per-version columns via +// render.GatherVersion -- the shared data both the "horizontal" +// (readings.html) and "vertical" (readings-vertical.html) templates range +// over; only the surrounding markup differs between the two layouts. +func buildColumnViews(secs []liturgy.Section, versions []string, lectionary string) []sectionView { views := make([]sectionView, 0, len(secs)) for _, sec := range secs { isPsalm := sec.PartID == "psalm" @@ -96,15 +154,89 @@ func RenderReadings(secs []liturgy.Section, versions []string, lectionary string Columns: cols, }) } + return views +} - var buf bytes.Buffer - if err := tmpl.ExecuteTemplate(&buf, "readings.html", views); err != nil { - // Should be unreachable (the template is embedded and fixed at - // build time); degrade to a visible, escaped error rather than - // panicking a request handler in the caller. - return template.HTML("<p class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</p>") +// interlinearVersions maps versions through the same pl->wuj substitution +// render.OfflineVersions performs for offline mode: "pl" (niedziela.pl +// paragraph text) carries no verse numbers and cannot interleave, so it is +// dropped, substituting "wuj" (the Polish-language bible version) in its +// place unless "wuj" was already selected. Reuses render.OfflineVersions +// rather than duplicating its two-line transform. +func interlinearVersions(versions []string) []string { + return render.OfflineVersions(versions) +} + +// buildInterlinearViews gathers each requested version's verses via +// render.GatherVerses (after the pl->wuj substitution, see +// interlinearVersions) and interleaves them by chapter:verse: the ordered +// union of keys is taken from the first versified version's own verse +// order, then any keys that only appear in a later version are appended in +// that version's order (stable, no duplicates). A version that comes back +// unversified (a bible lookup miss) is simply skipped -- the remaining +// versions still render. A section where nothing could be interleaved gets +// a short escaped Note instead of an empty Verses list. +func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary string) []ilSectionView { + mapped := interlinearVersions(versions) + + type verseSet struct { + label string + verses []bible.Verse } - return template.HTML(buf.String()) + + views := make([]ilSectionView, 0, len(secs)) + for _, sec := range secs { + view := ilSectionView{Heading: sec.Heading, Subtitle: sec.Subtitle, PartID: sec.PartID} + + var sets []verseSet + for _, v := range mapped { + label, verses, versified := render.GatherVerses(v, sec, lectionary) + if !versified { + continue + } + sets = append(sets, verseSet{label: label, verses: verses}) + } + + if len(sets) == 0 { + view.Note = "(brak wersetów do zestawienia interlinearnego)" + views = append(views, view) + continue + } + + type vkey struct{ chapter, verse int } + order := make([]vkey, 0) + seen := map[vkey]bool{} + bySet := make([]map[vkey]bible.Verse, len(sets)) + for i, s := range sets { + m := make(map[vkey]bible.Verse, len(s.verses)) + for _, v := range s.verses { + k := vkey{v.Chapter, v.Verse} + m[k] = v + if !seen[k] { + seen[k] = true + order = append(order, k) + } + } + bySet[i] = m + } + + vviews := make([]ilVerseView, 0, len(order)) + for _, k := range order { + var lines []ilLineView + for i, s := range sets { + if v, ok := bySet[i][k]; ok { + lines = append(lines, ilLineView{Label: s.label, Text: v.Text}) + } + } + vviews = append(vviews, ilVerseView{ + VNum: fmt.Sprintf("%d:%d", k.chapter, k.verse), + Lines: lines, + }) + } + view.Verses = vviews + views = append(views, view) + } + return views } // Themes returns the sorted, deduplicated union of the embedded theme diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 7ee666a..974c6f1 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -11,7 +11,7 @@ import ( func TestRenderReadings(t *testing.T) { secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} - html := string(RenderReadings(secs, []string{"wuj"}, "new")) + html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal")) if !strings.Contains(html, "Ewangelia") || !strings.Contains(html, "class=") { t.Errorf("reading pane missing heading/classes: %q", html[:min(200, len(html))]) } @@ -45,7 +45,7 @@ func TestRenderReadingsEscapesScriptText(t *testing.T) { PartID: "pierwsze_czytanie", Paragraphs: [][]string{{"<script>alert(1)</script>"}}, }} - html := string(RenderReadings(secs, []string{"pl"}, "new")) + html := string(RenderReadings(secs, []string{"pl"}, "new", "horizontal")) if strings.Contains(html, "<script>alert(1)</script>") { t.Errorf("raw <script> leaked into rendered output: %q", html) } @@ -54,6 +54,60 @@ func TestRenderReadingsEscapesScriptText(t *testing.T) { } } +func TestRenderReadingsVertical(t *testing.T) { + secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} + html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "vertical")) + if !strings.Contains(html, "display-vertical") { + t.Errorf("vertical output missing display-vertical container: %q", html[:min(300, len(html))]) + } + if !strings.Contains(html, "vcol") { + t.Errorf("vertical output missing vcol columns: %q", html[:min(300, len(html))]) + } + if !strings.Contains(html, "Wujek") || !strings.Contains(html, "Wulgata") { + t.Errorf("vertical output missing both version labels: %q", html[:min(300, len(html))]) + } +} + +func TestRenderReadingsInterlinear(t *testing.T) { + secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} + html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "interlinear")) + if !strings.Contains(html, "ilverse") { + t.Errorf("interlinear output missing ilverse: %q", html[:min(300, len(html))]) + } + if !strings.Contains(html, `class="vnum"`) { + t.Errorf("interlinear output missing vnum: %q", html[:min(300, len(html))]) + } + // The first ilverse block should group both version labels under one key. + // Bound it by the next vnum span (each ilverse carries exactly one). + i := strings.Index(html, `class="vnum"`) + block := html[i:] + if j := strings.Index(html[i+1:], `class="vnum"`); j != -1 { + block = html[i : i+1+j] + } + if !strings.Contains(block, "Wujek") || !strings.Contains(block, "Wulgata") { + t.Errorf("first interlinear verse block missing both version labels: %q", block) + } +} + +func TestRenderReadingsInterlinearExcludesPL(t *testing.T) { + secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} + html := string(RenderReadings(secs, []string{"pl", "vul"}, "new", "interlinear")) + if strings.Contains(html, "Polski (niedziela.pl)") { + t.Errorf("interlinear output should substitute wuj for pl, not carry pl's label: %q", html[:min(300, len(html))]) + } + if !strings.Contains(html, "Wujek") { + t.Errorf("interlinear output should substitute wuj for pl: %q", html[:min(300, len(html))]) + } +} + +func TestRenderReadingsInterlinearNoVersifiedNote(t *testing.T) { + secs := []liturgy.Section{{Heading: "Bez odwołania", PartID: "ewangelia"}} + html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear")) + if strings.Contains(html, "ilverse") { + t.Errorf("expected no ilverse blocks when nothing resolves: %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 index b1a483f..d484b69 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -6,6 +6,7 @@ package web import ( + "bytes" "fmt" "html/template" "io" @@ -101,6 +102,33 @@ func requestLectionary(cfg config.Config, r *http.Request) string { return cfg.Lectionary } +// requestDisplay returns the ?display= override (normalized the same way +// config.Load normalizes cfg.WebDisplay, so a bad/unknown value falls back +// to "horizontal" rather than reaching RenderReadings unchecked), falling +// back to cfg.WebDisplay when the param is absent. +func requestDisplay(cfg config.Config, r *http.Request) string { + if d := r.URL.Query().Get("display"); d != "" { + return config.NormalizeDisplay(d) + } + return cfg.WebDisplay +} + +// resolveQuery resolves the date/lectionary/all/versions/display controls +// shared by indexHandler and readingsHandler from cfg (the defaults) and +// r's query params (the overrides) -- one place for both handlers so they +// can't drift. +func resolveQuery(cfg config.Config, r *http.Request) (date, lectionary string, all bool, versions []string, display string) { + date = r.URL.Query().Get("date") + if date == "" { + date = today() + } + lectionary = requestLectionary(cfg, r) + all = queryBool(r, "all", cfg.All) + versions = requestVersions(cfg, r) + display = requestDisplay(cfg, r) + return date, lectionary, all, versions, display +} + // 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 @@ -127,6 +155,7 @@ type indexData struct { All bool ThemeOpts []themeOpt Theme string + Display string Ref string Lookup template.HTML Reading template.HTML @@ -147,20 +176,14 @@ type themeOpt struct { // 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) + date, lectionary, all, versions, display := resolveQuery(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) + reading := renderOrError(secs, loadVersions, lectionary, display, err) selected := map[string]bool{} for _, v := range versions { @@ -177,6 +200,12 @@ func indexHandler(cfg config.Config) http.HandlerFunc { themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme}) } + ref := r.URL.Query().Get("ref") // echoed into the lookup box on a shared/bookmarked link + var lookup template.HTML + if ref != "" { + lookup = renderLookup(ref, versions) + } + data := indexData{ Date: date, PrevDate: shiftDate(date, -1), @@ -186,7 +215,9 @@ func indexHandler(cfg config.Config) http.HandlerFunc { All: all, ThemeOpts: themeOpts, Theme: theme, - Ref: r.URL.Query().Get("ref"), // echoed into the lookup box on a shared/bookmarked link + Display: display, + Ref: ref, + Lookup: lookup, Reading: reading, } @@ -201,16 +232,10 @@ func indexHandler(cfg config.Config) http.HandlerFunc { // 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) + date, lectionary, all, versions, display := resolveQuery(cfg, r) secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) - reading := renderOrError(secs, loadVersions, lectionary, err) + reading := renderOrError(secs, loadVersions, lectionary, display, err) w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, string(reading)) @@ -221,14 +246,14 @@ func readingsHandler(cfg config.Config) http.HandlerFunc { // 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 { +func renderOrError(secs []liturgy.Section, versions []string, lectionary, display 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) + return RenderReadings(secs, versions, lectionary, display) } // lookupColumn is what templates/lookup.html ranges over: one per requested @@ -239,29 +264,38 @@ type lookupColumn struct { 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. +// renderLookup renders the passage-lookup fragment for ref (looked up as-is +// -- already English/kjv-style, e.g. "J 20:1" -- against each of versions +// via bible.Lookup directly; no Polish->English conversion, that only +// applies to a section's own citation via render.GatherVersion, not a +// free-typed lookup) against each requested version. Shared by lookupHandler +// (the HTMX partial) and indexHandler (so a bookmarked/shared "/?ref=..." +// link shows the same result instead of an empty pane). An empty ref +// renders nothing, matching lookupHandler's previous no-op behavior. +func renderLookup(ref string, versions []string) template.HTML { + if ref == "" { + return "" + } + 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}) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "lookup.html", cols); err != nil { + return template.HTML(`<p class="error">` + template.HTMLEscapeString(err.Error()) + `</p>`) + } + return template.HTML(buf.String()) +} + +// lookupHandler serves the passage-lookup fragment via renderLookup (see +// its doc comment for the lookup semantics). 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) - } + io.WriteString(w, string(renderLookup(ref, requestVersions(cfg, r)))) } } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 9e37177..b5cf78a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -104,4 +104,95 @@ func TestServer(t *testing.T) { t.Error("theme.css fallback served empty body") } }) + + t.Run("readings partial interlinear", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&v=vul&display=interlinear", 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) + } + i := strings.Index(body, `class="vnum"`) + if i == -1 { + t.Fatalf("interlinear partial missing a vnum verse label: %q", body) + } + // Bound the first ilverse block by the next vnum span (each ilverse + // carries exactly one), so the check covers every version's line + // grouped under that one key, not just the first. + block := body[i:] + if j := strings.Index(body[i+1:], `class="vnum"`); j != -1 { + block = body[i : i+1+j] + } + if !strings.Contains(block, "Wujek") || !strings.Contains(block, "Wulgata") { + t.Errorf("interlinear verse block missing both version labels grouped together: %q", block) + } + }) + + t.Run("readings partial vertical", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&v=vul&display=vertical", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "display-vertical") { + t.Errorf("vertical partial missing display-vertical: %q", rec.Body.String()) + } + }) + + t.Run("readings partial interlinear substitutes pl", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=pl&display=interlinear", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Wujek") { + t.Errorf("pl should be substituted with wuj in interlinear mode: %q", body) + } + if strings.Contains(body, "Polski (niedziela.pl)") { + t.Errorf("pl paragraph column should not appear in interlinear mode: %q", body) + } + }) + + t.Run("readings partial bad display falls back to horizontal", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&display=bogus", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "display-vertical") || strings.Contains(body, "ilverse") { + t.Errorf("bad display should fall back to horizontal, got: %q", body) + } + }) + + t.Run("index page seeds lookup results from ?ref=", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?ref=J+20:1&v=wuj", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `id="lookup-results"`) { + t.Fatalf("body missing lookup-results container: %q", body) + } + i := strings.Index(body, `id="lookup-results"`) + if !strings.Contains(body[i:], "20:1") { + t.Errorf("lookup-results not populated from ?ref=: %q", body[i:min(i+400, len(body))]) + } + }) + + t.Run("index page seeds display select from cfg", 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) + } + if !strings.Contains(rec.Body.String(), `name="display"`) { + t.Errorf("body missing display select: %q", rec.Body.String()) + } + }) } diff --git a/internal/web/static/base.css b/internal/web/static/base.css index f99b4b5..220badd 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -144,6 +144,51 @@ body { margin-right: 0.35em; } +/* Vertical display: versions side by side in columns, like the CLI + * `compare` view. A fixed --vcols track count (not auto-fit) means the + * grid's total width is always the page container's, so it wraps extra + * versions onto further rows instead of ever forcing horizontal scroll. */ +.display-vertical { + display: grid; + grid-template-columns: repeat(var(--vcols, 2), 1fr); + gap: var(--space-3) var(--space-4); + align-items: start; +} + +.vcol { + min-width: 0; +} + +/* Interlinear display: one .ilverse block per chapter:verse key, one + * .illine per version that carries it. */ +.display-interlinear { + display: flex; + flex-direction: column; +} + +.ilverse { + margin: 0 0 var(--space-3); + padding-top: var(--space-1); + border-top-width: 1px; + border-top-style: solid; +} + +.ilverse:first-child { + border-top-width: 0; + padding-top: 0; +} + +.illine { + margin: 0.2rem 0 0.2rem var(--space-2); +} + +.illine .version-label { + display: inline; + margin: 0 0.5em 0 0; + padding-bottom: 0; + border-bottom-width: 0; +} + a { text-decoration: underline; text-underline-offset: 0.15em; @@ -160,4 +205,7 @@ a { .heading { font-size: 1.25rem; } + .display-vertical { + grid-template-columns: 1fr; + } } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 4dd580f..03c0078 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,11 +1,13 @@ {{/* 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). + lectionary, version checkboxes, all/gospel toggle, display select, + 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 + params -- no hx-include needed), so "display" needs no extra wiring + beyond living inside #controls. 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. */}} @@ -48,6 +50,14 @@ <option value="1" {{if .All}}selected{{end}}>wszystkie części</option> </select> </label> + + <label>układ + <select name="display"> + <option value="horizontal" {{if eq .Display "horizontal"}}selected{{end}}>poziomo</option> + <option value="vertical" {{if eq .Display "vertical"}}selected{{end}}>kolumny</option> + <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>interlinearnie</option> + </select> + </label> </form> <label class="theme-picker">motyw diff --git a/internal/web/templates/readings-interlinear.html b/internal/web/templates/readings-interlinear.html new file mode 100644 index 0000000..0ba7c02 --- /dev/null +++ b/internal/web/templates/readings-interlinear.html @@ -0,0 +1,29 @@ +{{/* readings-interlinear.html renders the reading pane fragment in + interlinear (verse-by-verse) layout: one <section> per liturgy.Section, + versions interleaved by chapter:verse -- each verse key printed once + (.vnum) inside a .ilverse block, followed by one .illine per version + that carries that verse, carrying its own .version-label + escaped + text. "pl" cannot participate (paragraph text has no verse numbers) + and was already substituted/dropped upstream; see + web.buildInterlinearViews. A section with nothing to interleave shows + .Note instead. */}} +{{range .}} +<section class="reading-section" data-part="{{.PartID}}"> + <h2 class="heading">{{.Heading}}</h2> + {{if .Subtitle}}<p class="citation">{{.Subtitle}}</p>{{end}} + {{if .Note}} + <p class="error">{{.Note}}</p> + {{else}} + <div class="display-interlinear"> + {{range .Verses}} + <div class="ilverse"> + <span class="vnum">{{.VNum}}</span> + {{range .Lines}} + <div class="illine"><span class="version-label">{{.Label}}</span> <span>{{.Text}}</span></div> + {{end}} + </div> + {{end}} + </div> + {{end}} +</section> +{{end}} diff --git a/internal/web/templates/readings-vertical.html b/internal/web/templates/readings-vertical.html new file mode 100644 index 0000000..08f526d --- /dev/null +++ b/internal/web/templates/readings-vertical.html @@ -0,0 +1,29 @@ +{{/* readings-vertical.html renders the reading pane fragment in vertical + (side-by-side columns) layout: one <section> per liturgy.Section, all + requested versions laid out in a .display-vertical grid, one .vcol per + version -- the browser equivalent of the CLI `compare` view. Data and + role classes (heading/citation/version-label/vnum/refrain) match + readings.html so theme CSS restyles both identically; base.css owns + the .display-vertical/.vcol layout. */}} +{{range .}} +<section class="reading-section" data-part="{{.PartID}}"> + <h2 class="heading">{{.Heading}}</h2> + {{if .Subtitle}}<p class="citation">{{.Subtitle}}</p>{{end}} + <div class="display-vertical"> + {{range .Columns}} + <div class="vcol"> + <h3 class="version-label">{{.Label}}</h3> + {{range .Blocks}} + {{if .Refrain}} + <p class="block refrain">{{.Text}}</p> + {{else if .VNum}} + <p class="block verse"><span class="vnum">{{.VNum}}</span> {{.Text}}</p> + {{else}} + <p class="block">{{.Text}}</p> + {{end}} + {{end}} + </div> + {{end}} + </div> +</section> +{{end}} |
