aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/config/config.go24
-rw-r--r--internal/config/config.toml1
-rw-r--r--internal/config/config_test.go40
-rw-r--r--internal/render/render.go69
-rw-r--r--internal/render/render_test.go31
-rw-r--r--internal/web/render.go160
-rw-r--r--internal/web/render_test.go58
-rw-r--r--internal/web/server.go110
-rw-r--r--internal/web/server_test.go91
-rw-r--r--internal/web/static/base.css48
-rw-r--r--internal/web/templates/index.html16
-rw-r--r--internal/web/templates/readings-interlinear.html29
-rw-r--r--internal/web/templates/readings-vertical.html29
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}}