summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go33
-rw-r--r--internal/cli/cli_test.go40
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/liturgy/fetch.go84
-rw-r--r--internal/liturgy/fetch_test.go16
-rw-r--r--internal/liturgy/parse.go53
-rw-r--r--internal/liturgy/parse_test.go53
-rw-r--r--internal/liturgy/section.go19
-rw-r--r--internal/liturgy/store.go2
-rw-r--r--internal/readings/readings.go20
-rw-r--r--internal/readings/readings_test.go12
-rw-r--r--internal/tradlit/parse_test.go53
-rw-r--r--internal/tradlit/tradlit.go63
-rw-r--r--internal/tradlit/tradlit_test.go14
-rw-r--r--internal/tui/tui.go68
-rw-r--r--internal/tui/tui_test.go35
-rw-r--r--internal/web/render.go39
-rw-r--r--internal/web/render_test.go56
-rw-r--r--internal/web/server.go23
-rw-r--r--internal/web/server_test.go9
-rw-r--r--internal/web/static/base.css17
21 files changed, 599 insertions, 112 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index f4aec28..5954e23 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -329,7 +329,7 @@ func cacheTraditionalRange(from, to, lang string) int {
cached := 0
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
- if _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil {
+ if _, _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil {
cached++
}
}
@@ -383,7 +383,7 @@ func formatFreed(bytes int64) string {
// -b/--bible): fetch via the readings router, apply the offline version
// swap, then render each section's heading and render.GatherVersion blocks.
func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int {
- secs, err := readings.Load(cfg, readings.Options{
+ secs, dayInfo, err := readings.Load(cfg, readings.Options{
Date: date,
Refresh: refresh,
Offline: cfg.Offline,
@@ -405,6 +405,7 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width
w := resolveWidth(width, false, stdout)
if !raw {
+ printDayInfo(stdout, dayInfo)
banner := bannerFor(cfg.UILanguage, all, date)
fmt.Fprintln(stdout, banner)
fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w)))
@@ -419,6 +420,31 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width
return 0
}
+// printDayInfo prints the day's celebration name -- and, if the source
+// carries one, its temporal Season on its own line -- above the banner.
+// Name/Season are never translated (source-language, like the readings/
+// citations themselves; see liturgy.DayInfo). A source that yielded no
+// DayInfo (info.Name == "") prints nothing: the header is a nice-to-have,
+// never an error condition. Callers only reach this when !raw; --raw skips
+// it entirely, keeping piped output text-only.
+//
+// The CLI has no ANSI styling of its own (unlike the TUI's Faint/dim
+// styles), so "dim" here is expressed structurally: the Season, if any,
+// gets its own line under Name rather than sharing emphasis with it.
+func printDayInfo(stdout io.Writer, info liturgy.DayInfo) {
+ if info.Name == "" {
+ return
+ }
+ name := info.Name
+ if info.Colour != "" {
+ name += " · " + info.Colour
+ }
+ fmt.Fprintln(stdout, name)
+ if info.Season != "" {
+ fmt.Fprintln(stdout, info.Season)
+ }
+}
+
// bannerFor builds the "<Gospel|Readings> <connective> DATE" banner: the
// "readings" word when every part is shown, "gospel" for the gospel-only
// default, and the connective between word and date, all localised via
@@ -475,7 +501,7 @@ func renderCompare(cfg config.Config, list, date string, all, raw bool, width in
}
versions = render.EffectiveVersions(versions, cfg.Lectionary, cfg.Offline)
- secs, err := readings.Load(cfg, readings.Options{
+ secs, dayInfo, err := readings.Load(cfg, readings.Options{
Date: date,
Refresh: refresh,
Offline: cfg.Offline,
@@ -493,6 +519,7 @@ func renderCompare(cfg config.Config, list, date string, all, raw bool, width in
w := resolveWidth(width, true, stdout)
if !raw {
+ printDayInfo(stdout, dayInfo)
banner := bannerFor(cfg.UILanguage, all, date)
fmt.Fprintln(stdout, banner)
fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w)))
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 5004af4..6fc5e0e 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -2,10 +2,14 @@ package cli
import (
"bytes"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
)
func TestHelp(t *testing.T) {
@@ -189,6 +193,42 @@ func TestCleanPreferredOverUpdate(t *testing.T) {
}
}
+// TestDayInfoHeaderShown exercises fetchAndPrint's day-info header end to
+// end (via Run against a fixture server, matching internal/readings'
+// TestLoadModernRoutes): the day's celebration name appears above the
+// banner for the default (non-raw) render, and is entirely absent from
+// --raw output, which stays text-only for piping.
+func TestDayInfoHeaderShown(t *testing.T) {
+ html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write(html)
+ }))
+ defer srv.Close()
+ liturgy.SetBaseURL(srv.URL + "/liturgia/%s/Ewangelia")
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ var out, errb bytes.Buffer
+ if code := Run([]string{"2026-07-22"}, nil, &out, &errb); code != 0 {
+ t.Fatalf("Run code=%d stderr=%q", code, errb.String())
+ }
+ if !strings.Contains(out.String(), "Święto św. Marii Magdaleny") {
+ t.Errorf("stdout missing day-info header: %q", out.String())
+ }
+
+ out.Reset()
+ errb.Reset()
+ if code := Run([]string{"2026-07-22", "-r"}, nil, &out, &errb); code != 0 {
+ t.Fatalf("Run --raw code=%d stderr=%q", code, errb.String())
+ }
+ if strings.Contains(out.String(), "Święto św. Marii Magdaleny") {
+ t.Errorf("--raw stdout should omit the day-info header: %q", out.String())
+ }
+}
+
// TestDateTokenAnyPosition exercises extractDate directly: the date token
// is found regardless of where it appears among other flags.
func TestDateTokenAnyPosition(t *testing.T) {
diff --git a/internal/config/config.go b/internal/config/config.go
index bee48a7..69d7d2b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,7 +24,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.4.0"
+const Version = "0.5.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go
index 8c887f4..e66b77f 100644
--- a/internal/liturgy/fetch.go
+++ b/internal/liturgy/fetch.go
@@ -76,25 +76,29 @@ func CacheDir() string {
return filepath.Join(base, "lectio")
}
-// Load returns the day's reading sections, from the modern (niedziela.pl)
-// source, preferring cache over network:
+// Load returns the day's reading sections and DayInfo, from the modern
+// (niedziela.pl) source, preferring cache over network:
//
-// 0. If Offline, skip straight to LoadOffline (the harvested sigla TSV).
-// 1. Unless Refresh, the parsed JSON cache ({date}.json).
-// 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed, and the
-// result written to the JSON cache for next time.
+// 0. If Offline, skip straight to LoadOffline (the harvested sigla TSV) --
+// that store carries citations only, so DayInfo comes back zero.
+// 1. Unless Refresh, the parsed JSON cache ({date}.json), which round-trips
+// DayInfo alongside the sections (see cachedDay/loadCache).
+// 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed (both
+// sections and DayInfo re-derived from the same HTML via Parse/
+// ParseDayInfo), and the result written to the JSON cache for next time.
// 3. Otherwise, fetch the page over the network, parse it, and -- only if
// the page is fully published -- write both cache layers. A fetch error
// here (e.g. no network) falls back to LoadOffline for this date if it
// has been harvested, and only surfaces the original fetch error if
// that fallback also fails.
-func Load(opts Options) ([]Section, error) {
+func Load(opts Options) ([]Section, DayInfo, error) {
if !dateRe.MatchString(opts.Date) {
- return nil, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
+ return nil, DayInfo{}, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
}
if opts.Offline {
- return LoadOffline(opts.Date)
+ secs, err := LoadOffline(opts.Date)
+ return secs, DayInfo{}, err
}
dir := CacheDir()
@@ -102,27 +106,30 @@ func Load(opts Options) ([]Section, error) {
htmlPath := filepath.Join(dir, opts.Date+".html")
if !opts.Refresh {
- if secs, err := loadJSONCache(jsonPath); err == nil {
- return secs, nil
+ if secs, info, err := loadCache(jsonPath); err == nil {
+ return secs, info, nil
}
if page, err := os.ReadFile(htmlPath); err == nil {
- secs, err := Parse(string(page))
+ pageStr := string(page)
+ secs, err := Parse(pageStr)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
- writeJSONCache(jsonPath, secs)
- return secs, nil
+ info := ParseDayInfo(pageStr)
+ writeCache(jsonPath, secs, info)
+ return secs, info, nil
}
}
page, err := fetch(opts.Date)
if err != nil {
// Network is unreachable: fall back to a prior harvest of this date
- // if there is one, rather than failing outright.
+ // if there is one, rather than failing outright. The sigla store
+ // carries no DayInfo, so this path always reports it zero.
if secs, offErr := LoadOffline(opts.Date); offErr == nil {
- return secs, nil
+ return secs, DayInfo{}, nil
}
- return nil, err
+ return nil, DayInfo{}, err
}
// Parse itself reports an unpublished date as "no reading published for
@@ -130,19 +137,20 @@ func Load(opts Options) ([]Section, error) {
// page is neither cached nor returned as sections here.
secs, err := Parse(page)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
+ info := ParseDayInfo(page)
// Cache only fully-published pages, so an as-yet-unpublished future date
// keeps being retried instead of caching a "no reading" placeholder.
if publishedRe.MatchString(page) {
if err := os.MkdirAll(dir, 0o755); err == nil {
_ = os.WriteFile(htmlPath, []byte(page), 0o644)
- writeJSONCache(jsonPath, secs)
+ writeCache(jsonPath, secs, info)
}
}
- return secs, nil
+ return secs, info, nil
}
// CleanCache removes cached readings whose date is before `before` from
@@ -189,23 +197,35 @@ func CleanCache(before time.Time) (removed int, freed int64, err error) {
return removed, freed, nil
}
-// loadJSONCache reads and unmarshals the parsed-sections cache file.
-func loadJSONCache(path string) ([]Section, error) {
+// cachedDay is the on-disk shape of the parsed-sections JSON cache
+// ({date}.json): the sections plus the day's liturgical identity, so a
+// cache hit round-trips DayInfo without re-parsing the HTML cache. (An
+// older cache file written before DayInfo existed was a bare JSON array,
+// not an object -- json.Unmarshal into cachedDay then fails, which
+// loadCache treats as an ordinary cache miss, falling through to the HTML
+// cache or the network like any other stale/missing cache entry.)
+type cachedDay struct {
+ Sections []Section
+ DayInfo DayInfo
+}
+
+// loadCache reads and unmarshals the parsed-sections + DayInfo cache file.
+func loadCache(path string) ([]Section, DayInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
- var secs []Section
- if err := json.Unmarshal(data, &secs); err != nil {
- return nil, err
+ var cd cachedDay
+ if err := json.Unmarshal(data, &cd); err != nil {
+ return nil, DayInfo{}, err
}
- return secs, nil
+ return cd.Sections, cd.DayInfo, nil
}
-// writeJSONCache best-effort writes the parsed sections cache; a failure to
-// cache should never fail the load itself.
-func writeJSONCache(path string, secs []Section) {
- data, err := json.Marshal(secs)
+// writeCache best-effort writes the parsed sections + DayInfo cache; a
+// failure to cache should never fail the load itself.
+func writeCache(path string, secs []Section, info DayInfo) {
+ data, err := json.Marshal(cachedDay{Sections: secs, DayInfo: info})
if err != nil {
return
}
diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go
index 738126d..c06f0f8 100644
--- a/internal/liturgy/fetch_test.go
+++ b/internal/liturgy/fetch_test.go
@@ -19,17 +19,25 @@ func TestLoadCaches(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
baseURL = srv.URL + "/liturgia/%s/Ewangelia" // test hook
- secs1, err := Load(Options{Date: "2026-06-22"})
+ secs1, info1, err := Load(Options{Date: "2026-06-22"})
if err != nil || len(secs1) == 0 {
t.Fatalf("load1: %v", err)
}
- secs2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache
+ if info1.Name == "" {
+ t.Error("load1: DayInfo.Name empty, want it populated from the freshly-parsed HTML")
+ }
+ secs2, info2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache
if hits != 1 {
t.Errorf("server hit %d times, want 1 (cache miss on repeat)", hits)
}
if len(secs2) != len(secs1) {
t.Error("cache returned different section count")
}
+ // The JSON cache round-trips DayInfo (see cachedDay/loadCache), so a
+ // cache-hit repeat load must not lose it.
+ if info2 != info1 {
+ t.Errorf("cache-hit DayInfo = %+v, want it to match the first load's %+v", info2, info1)
+ }
}
// TestLoadRejectsInvalidDate is the liturgy-layer defense-in-depth check for
@@ -39,7 +47,7 @@ func TestLoadCaches(t *testing.T) {
//
// The planted "passwd.json" sits one level *above* CacheDir() -- reachable
// only via a "../" date -- so if Load ever built jsonPath from the raw date
-// unchecked, loadJSONCache would read it back and return its section instead
+// unchecked, loadCache would read it back and return its section instead
// of an error.
func TestLoadRejectsInvalidDate(t *testing.T) {
dir := t.TempDir()
@@ -50,7 +58,7 @@ func TestLoadRejectsInvalidDate(t *testing.T) {
t.Fatal(err)
}
- secs, err := Load(Options{Date: "../passwd"})
+ secs, _, err := Load(Options{Date: "../passwd"})
if err == nil {
t.Fatalf("Load(Date=%q) = (%v, nil), want a non-nil error", "../passwd", secs)
}
diff --git a/internal/liturgy/parse.go b/internal/liturgy/parse.go
index 64e6928..4c1a935 100644
--- a/internal/liturgy/parse.go
+++ b/internal/liturgy/parse.go
@@ -23,8 +23,32 @@ var (
h4Re = regexp.MustCompile(`(?s)<h4>(.*?)</h4>`)
pRe = regexp.MustCompile(`(?s)<p>(.*?)</p>`)
citationRe = regexp.MustCompile(`\((.+)\)\s*$`)
+
+ // dayNamePRe matches every classed <p><em>...</em></p> on the page; only
+ // the one whose class carries both "fw-bold" and a "color-" role (see
+ // dayNameParaMatches) is the day's celebration name -- the page also
+ // carries a plain fw-bold (no color-) lookalike higher up that must not
+ // win instead.
+ dayNamePRe = regexp.MustCompile(`(?s)<p class="([^"]*)">\s*<em>(.*?)</em>\s*</p>`)
+
+ // dayColourRe matches niedziela.pl's "Kolor szat: <word>" vestment-colour
+ // line, tolerating the <span>/<strong> markup wrapped around the colour
+ // word on the page (see internal/liturgy/testdata/2026-07-22.html).
+ dayColourRe = regexp.MustCompile(`Kolor szat:\s*(?:<[^>]+>\s*)*([\p{L}]+)`)
)
+// modernColours maps niedziela.pl's Polish vestment-colour words to
+// DayInfo's normalized colour names; anything not listed here (including a
+// multi-option line like "zielony albo biały albo czerwony", which matches
+// only its first word) is left for the caller to treat as "" if absent.
+var modernColours = map[string]string{
+ "biały": "white",
+ "zielony": "green",
+ "fioletowy": "violet",
+ "czerwony": "red",
+ "różowy": "rose",
+}
+
// panePattern matches the opening tag of the tab-pane div carrying the given
// tab id, e.g. `<div class="tab-pane fade " id="tabnowy0all">`.
func panePattern(tab string) *regexp.Regexp {
@@ -122,6 +146,35 @@ func Parse(pageHTML string) ([]Section, error) {
return sections, nil
}
+// ParseDayInfo extracts the day's celebration name and liturgical colour
+// from a niedziela.pl page: Name is the inner text of the <p class="...
+// fw-bold color-XXX"><em>NAME</em></p> paragraph (there is also an earlier,
+// plain fw-bold-but-no-color- lookalike on the page -- see dayNamePRe --
+// which must not match instead), and Colour comes from the page's "Kolor
+// szat: <word>" line, mapped via modernColours (case-insensitive; unknown
+// word -> ""). Season is always "" -- the modern lectionary folds its
+// temporal context into Name on temporal days rather than carrying it
+// separately. A page whose markup doesn't match either pattern (a layout
+// change, or a fixture with neither) yields a zero DayInfo, never an error:
+// the readings are the load-bearing content, the header is a nice-to-have.
+func ParseDayInfo(pageHTML string) DayInfo {
+ var info DayInfo
+
+ for _, m := range dayNamePRe.FindAllStringSubmatch(pageHTML, -1) {
+ class := m[1]
+ if strings.Contains(class, "fw-bold") && strings.Contains(class, "color-") {
+ info.Name = strings.Join(htmlToLines(m[2]), " ")
+ break
+ }
+ }
+
+ if m := dayColourRe.FindStringSubmatch(pageHTML); m != nil {
+ info.Colour = modernColours[strings.ToLower(m[1])]
+ }
+
+ return info
+}
+
// partID assigns the stable liturgical-part identifier for a section heading.
// A second "1. czytanie" heading on the same day (a split feast offering two
// alternative first readings) becomes "drugie_czytanie" instead of colliding
diff --git a/internal/liturgy/parse_test.go b/internal/liturgy/parse_test.go
index 13732f9..8fe2eb0 100644
--- a/internal/liturgy/parse_test.go
+++ b/internal/liturgy/parse_test.go
@@ -56,6 +56,59 @@ func TestParse(t *testing.T) {
}
}
+// TestParseDayInfo checks the modern (niedziela.pl) day-info extraction
+// against the split-feast fixture: Name comes from the color-classed,
+// fw-bold <p><em>...</em></p> near id="dzien" (not the earlier, plain
+// fw-bold lookalike higher up the page), and Colour from "Kolor szat:
+// biały" -> "white". Season is always "" for the modern lectionary.
+func TestParseDayInfo(t *testing.T) {
+ html, err := os.ReadFile("testdata/2026-07-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := ParseDayInfo(string(html))
+ if !strings.Contains(info.Name, "Marii Magdaleny") {
+ t.Errorf("Name = %q, want it to contain %q", info.Name, "Marii Magdaleny")
+ }
+ if info.Colour != "white" {
+ t.Errorf("Colour = %q, want %q", info.Colour, "white")
+ }
+ if info.Season != "" {
+ t.Errorf("Season = %q, want empty for the modern lectionary", info.Season)
+ }
+}
+
+// TestParseDayInfoMultilineName checks the day name is cleanly joined when
+// the source <em> body itself carries an embedded line break (a long
+// commemoration name wrapped across lines in the page's own markup), and
+// that a multi-option colour line ("zielony albo biały albo czerwony")
+// maps by its first word.
+func TestParseDayInfoMultilineName(t *testing.T) {
+ html, err := os.ReadFile("testdata/2026-06-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := ParseDayInfo(string(html))
+ if !strings.Contains(info.Name, "Dzień Powszedni") || !strings.Contains(info.Name, "Jana Fishera") {
+ t.Errorf("Name = %q, want it to contain both wrapped-line fragments", info.Name)
+ }
+ if strings.Contains(info.Name, "\n") {
+ t.Errorf("Name = %q, should not contain a raw newline", info.Name)
+ }
+ if info.Colour != "green" {
+ t.Errorf("Colour = %q, want %q (first of \"zielony albo...\")", info.Colour, "green")
+ }
+}
+
+// TestParseDayInfoNoMatch checks an unrecognised page shape yields a zero
+// DayInfo rather than an error -- the header is simply omitted by callers.
+func TestParseDayInfoNoMatch(t *testing.T) {
+ info := ParseDayInfo("<html><body>redesigned</body></html>")
+ if info != (DayInfo{}) {
+ t.Errorf("ParseDayInfo(unrecognised) = %+v, want zero value", info)
+ }
+}
+
func TestParseLayoutChange(t *testing.T) {
if _, err := Parse("<html><body>redesigned</body></html>"); err == nil {
t.Error("expected error on missing reading tab")
diff --git a/internal/liturgy/section.go b/internal/liturgy/section.go
index 498b201..cacefb0 100644
--- a/internal/liturgy/section.go
+++ b/internal/liturgy/section.go
@@ -10,3 +10,22 @@ type Section struct {
Heading, Subtitle, Citation, PartID string
Paragraphs [][]string
}
+
+// DayInfo is the day's liturgical identity, source-language (Polish for the
+// modern niedziela.pl lectionary, English/Latin for the traditional
+// missalemeum) -- never translated, like Section's own Heading/Citation.
+// A source that carries no such data (or an unrecognised page/response
+// shape) yields a zero DayInfo; callers must treat that as "omit the
+// header", never as an error.
+type DayInfo struct {
+ // Name is the celebration, e.g. "Święto św. Marii Magdaleny" (modern) or
+ // "St. Mary Magdalene" (traditional).
+ Name string
+ // Season is the temporal context, e.g. "Feria IV after VIII Sunday
+ // after Pentecost" (traditional); always "" for the modern lectionary,
+ // whose temporal is folded into Name on temporal days.
+ Season string
+ // Colour is the normalized liturgical colour: "white", "green",
+ // "violet", "red", "rose", or "" when unknown/unmapped.
+ Colour string
+}
diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go
index 69e45f2..5bf7ef0 100644
--- a/internal/liturgy/store.go
+++ b/internal/liturgy/store.go
@@ -170,7 +170,7 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro
if publishedRe.MatchString(page) {
if mkErr := os.MkdirAll(dir, 0o755); mkErr == nil {
_ = os.WriteFile(filepath.Join(dir, dateStr+".html"), []byte(page), 0o644)
- writeJSONCache(filepath.Join(dir, dateStr+".json"), secs)
+ writeCache(filepath.Join(dir, dateStr+".json"), secs, ParseDayInfo(page))
}
}
diff --git a/internal/readings/readings.go b/internal/readings/readings.go
index ac7209a..047e901 100644
--- a/internal/readings/readings.go
+++ b/internal/readings/readings.go
@@ -28,29 +28,33 @@ type Options struct {
All bool
}
-// Load fetches the day's sections for the configured lectionary
-// (cfg.Lectionary: "traditional" or "new") and applies part filtering.
-// Returns liturgy.Section values regardless of source.
-func Load(cfg config.Config, opts Options) ([]liturgy.Section, error) {
+// Load fetches the day's sections and its DayInfo (celebration name,
+// temporal, liturgical colour -- see liturgy.DayInfo) for the configured
+// lectionary (cfg.Lectionary: "traditional" or "new") and applies part
+// filtering. Returns liturgy.Section values regardless of source. A source
+// that yields no DayInfo (see liturgy.Load/tradlit.Load) comes back as a
+// zero liturgy.DayInfo, not an error -- callers omit the header for it.
+func Load(cfg config.Config, opts Options) ([]liturgy.Section, liturgy.DayInfo, error) {
offline := opts.Offline || cfg.Offline
var secs []liturgy.Section
+ var info liturgy.DayInfo
var err error
if cfg.Lectionary == "traditional" {
- secs, err = tradlit.Load(opts.Date, cfg.TraditionalLang, offline)
+ secs, info, err = tradlit.Load(opts.Date, cfg.TraditionalLang, offline)
} else {
- secs, err = liturgy.Load(liturgy.Options{
+ secs, info, err = liturgy.Load(liturgy.Options{
Date: opts.Date,
Refresh: opts.Refresh,
Offline: offline,
})
}
if err != nil {
- return nil, err
+ return nil, liturgy.DayInfo{}, err
}
- return filterParts(secs, cfg, opts.All), nil
+ return filterParts(secs, cfg, opts.All), info, nil
}
// filterParts keeps only the gospel when !all (PartID "ewangelia" modern or
diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go
index 9a31802..c9e92cd 100644
--- a/internal/readings/readings_test.go
+++ b/internal/readings/readings_test.go
@@ -92,7 +92,7 @@ func TestLoadModernRoutes(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
cfg := config.Config{Lectionary: "new"}
- secs, err := Load(cfg, Options{Date: "2026-07-22", All: true})
+ secs, info, err := Load(cfg, Options{Date: "2026-07-22", All: true})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -106,6 +106,9 @@ func TestLoadModernRoutes(t *testing.T) {
if !found {
t.Errorf("no gospel section (PartID=ewangelia) found in %+v", secs)
}
+ if !strings.Contains(info.Name, "Marii Magdaleny") {
+ t.Errorf("DayInfo.Name = %q, want it to contain %q", info.Name, "Marii Magdaleny")
+ }
}
// TestLoadTraditionalOfflineErrorsWithoutCache exercises the (formerly
@@ -116,7 +119,7 @@ func TestLoadTraditionalOfflineErrorsWithoutCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"}
- _, err := Load(cfg, Options{Date: "2026-07-22", Offline: true})
+ _, _, err := Load(cfg, Options{Date: "2026-07-22", Offline: true})
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -146,11 +149,14 @@ func TestLoadTraditionalOfflineReadsCache(t *testing.T) {
}
cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"}
- secs, err := Load(cfg, Options{Date: "2026-07-22", Offline: true, All: true})
+ secs, info, err := Load(cfg, Options{Date: "2026-07-22", Offline: true, All: true})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(secs) == 0 {
t.Error("expected traditional offline sections, got none")
}
+ if info.Name != "St. Mary Magdalene" {
+ t.Errorf("DayInfo.Name = %q, want %q", info.Name, "St. Mary Magdalene")
+ }
}
diff --git a/internal/tradlit/parse_test.go b/internal/tradlit/parse_test.go
index a7344cd..58cc66f 100644
--- a/internal/tradlit/parse_test.go
+++ b/internal/tradlit/parse_test.go
@@ -2,7 +2,10 @@ package tradlit
import (
"os"
+ "strings"
"testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
)
func TestParse(t *testing.T) {
@@ -10,7 +13,7 @@ func TestParse(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- secs, err := Parse(body)
+ secs, _, err := Parse(body)
if err != nil {
t.Fatal(err)
}
@@ -33,3 +36,51 @@ func TestParse(t *testing.T) {
t.Errorf("missing parts: gospel=%v epistle=%v", gospel, epistle)
}
}
+
+// TestParseDayInfo checks the traditional (missalemeum) day-info
+// extraction against the fixture's "info" object: Name from info.title,
+// Season from info.tempora, Colour from info.colors[0] ("w" -> "white").
+func TestParseDayInfo(t *testing.T) {
+ body, err := os.ReadFile("testdata/2026-07-22.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, info, err := Parse(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Name != "St. Mary Magdalene" {
+ t.Errorf("Name = %q, want %q", info.Name, "St. Mary Magdalene")
+ }
+ if !strings.Contains(info.Season, "Pentecost") {
+ t.Errorf("Season = %q, want it to contain %q", info.Season, "Pentecost")
+ }
+ if info.Colour != "white" {
+ t.Errorf("Colour = %q, want %q", info.Colour, "white")
+ }
+}
+
+// TestParseDayInfoMissingInfo checks that a response with no (or empty)
+// info object yields a zero DayInfo rather than an error.
+func TestParseDayInfoMissingInfo(t *testing.T) {
+ _, info, err := Parse([]byte(`[{"sections":[]}]`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info != (liturgy.DayInfo{}) {
+ t.Errorf("info = %+v, want zero value for a response with no info object", info)
+ }
+}
+
+// TestParseDayInfoUnknownColourCode checks an unrecognised colour code maps
+// to "" rather than passing the raw code through.
+func TestParseDayInfoUnknownColourCode(t *testing.T) {
+ body := []byte(`[{"info":{"title":"Test","tempora":"","colors":["z"]},"sections":[]}]`)
+ _, info, err := Parse(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Colour != "" {
+ t.Errorf("Colour = %q, want empty for unknown code %q", info.Colour, "z")
+ }
+}
diff --git a/internal/tradlit/tradlit.go b/internal/tradlit/tradlit.go
index a7b53bc..b464e56 100644
--- a/internal/tradlit/tradlit.go
+++ b/internal/tradlit/tradlit.go
@@ -34,29 +34,66 @@ var citationRe = regexp.MustCompile(`\*([^*\n]+)\*`)
// apiResponse mirrors the shape of the missalemeum proper-of-the-day API:
// a single-element list of { info, sections }.
type apiResponse struct {
- Info struct {
- Title string `json:"title"`
- } `json:"info"`
+ Info apiInfo `json:"info"`
Sections []apiSection `json:"sections"`
}
+// apiInfo mirrors the missalemeum API's "info" object: the day's celebration
+// title, its temporal context ("tempora"), and its liturgical colour code(s)
+// -- see dayInfoFromAPI, which turns it into a liturgy.DayInfo.
+type apiInfo struct {
+ Title string `json:"title"`
+ Tempora string `json:"tempora"`
+ Colors []string `json:"colors"`
+}
+
type apiSection struct {
ID string `json:"id"`
Label string `json:"label"`
Body [][]string `json:"body"`
}
+// tradColours maps missalemeum's single-letter liturgical colour codes to
+// DayInfo's normalized colour names; a code not listed here (or an empty
+// Colors list) leaves DayInfo.Colour "".
+var tradColours = map[string]string{
+ "w": "white",
+ "r": "red",
+ "v": "violet",
+ "g": "green",
+ "p": "rose",
+}
+
+// dayInfoFromAPI turns a response's info object into a liturgy.DayInfo:
+// Title -> Name, Tempora -> Season, and the first Colors code -> Colour via
+// tradColours (unknown/missing -> ""). A zero-value apiInfo (no "info" key
+// in the response) yields a zero DayInfo.
+func dayInfoFromAPI(info apiInfo) liturgy.DayInfo {
+ colour := ""
+ if len(info.Colors) > 0 {
+ colour = tradColours[strings.ToLower(info.Colors[0])]
+ }
+ return liturgy.DayInfo{
+ Name: info.Title,
+ Season: info.Tempora,
+ Colour: colour,
+ }
+}
+
// Parse decodes a missalemeum proper-of-the-day API response body into
-// liturgy.Sections. Sections with an empty id or empty body are skipped.
-func Parse(jsonBody []byte) ([]liturgy.Section, error) {
+// liturgy.Sections plus the day's liturgical identity (its "info" object,
+// see dayInfoFromAPI). Sections with an empty id or empty body are skipped.
+func Parse(jsonBody []byte) ([]liturgy.Section, liturgy.DayInfo, error) {
var resp []apiResponse
if err := json.Unmarshal(jsonBody, &resp); err != nil {
- return nil, fmt.Errorf("tradlit: parse: %w", err)
+ return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: parse: %w", err)
}
if len(resp) == 0 {
- return nil, fmt.Errorf("tradlit: parse: empty response")
+ return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: parse: empty response")
}
+ info := dayInfoFromAPI(resp[0].Info)
+
var out []liturgy.Section
for _, sec := range resp[0].Sections {
if sec.ID == "" || len(sec.Body) == 0 || len(sec.Body[0]) == 0 {
@@ -85,7 +122,7 @@ func Parse(jsonBody []byte) ([]liturgy.Section, error) {
Paragraphs: [][]string{lines},
})
}
- return out, nil
+ return out, info, nil
}
// cachePath returns where Load caches a (date, lang) day's raw API response:
@@ -108,7 +145,7 @@ func cachePath(date, lang string) string {
// the cache path (best-effort -- a cache-write failure never fails the
// request) before being parsed. On HTTP 404 (no propers published for
// that date) it returns a clear error and writes nothing to the cache.
-func Load(date, lang string, offline bool) ([]liturgy.Section, error) {
+func Load(date, lang string, offline bool) ([]liturgy.Section, liturgy.DayInfo, error) {
if offline {
return loadCached(date, lang)
}
@@ -116,20 +153,20 @@ func Load(date, lang string, offline bool) ([]liturgy.Section, error) {
}
// loadCached implements Load's offline path.
-func loadCached(date, lang string) ([]liturgy.Section, error) {
+func loadCached(date, lang string) ([]liturgy.Section, liturgy.DayInfo, error) {
body, err := os.ReadFile(cachePath(date, lang))
if err != nil {
- return nil, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang)
+ return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang)
}
return Parse(body)
}
// loadLive implements Load's online path: fetch, best-effort cache the raw
// body, then parse.
-func loadLive(date, lang string) ([]liturgy.Section, error) {
+func loadLive(date, lang string) ([]liturgy.Section, liturgy.DayInfo, error) {
body, err := fetch(date, lang)
if err != nil {
- return nil, err
+ return nil, liturgy.DayInfo{}, err
}
writeCache(date, lang, body)
return Parse(body)
diff --git a/internal/tradlit/tradlit_test.go b/internal/tradlit/tradlit_test.go
index cd2cc83..8aa46eb 100644
--- a/internal/tradlit/tradlit_test.go
+++ b/internal/tradlit/tradlit_test.go
@@ -32,13 +32,16 @@ func TestLoadOnlineCachesRawBody(t *testing.T) {
defer func() { baseURL = orig }()
t.Setenv("XDG_CACHE_HOME", t.TempDir())
- secs, err := Load("2026-07-22", "en", false)
+ secs, info, err := Load("2026-07-22", "en", false)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(secs) == 0 {
t.Fatal("Load returned no sections")
}
+ if info.Name != "St. Mary Magdalene" {
+ t.Errorf("Load DayInfo.Name = %q, want %q", info.Name, "St. Mary Magdalene")
+ }
if hits != 1 {
t.Errorf("server hit %d times, want 1", hits)
}
@@ -66,7 +69,7 @@ func TestLoadOnline404WritesNoCache(t *testing.T) {
defer func() { baseURL = orig }()
t.Setenv("XDG_CACHE_HOME", t.TempDir())
- _, err := Load("2026-07-22", "en", false)
+ _, _, err := Load("2026-07-22", "en", false)
if err == nil {
t.Fatal("expected error on 404, got nil")
}
@@ -95,13 +98,16 @@ func TestLoadOfflineReadsCache(t *testing.T) {
t.Fatal(err)
}
- secs, err := Load("2026-07-22", "pl", true)
+ secs, info, err := Load("2026-07-22", "pl", true)
if err != nil {
t.Fatalf("Load offline: %v", err)
}
if len(secs) == 0 {
t.Fatal("Load offline returned no sections")
}
+ if info.Name != "St. Mary Magdalene" {
+ t.Errorf("Load offline DayInfo.Name = %q, want %q", info.Name, "St. Mary Magdalene")
+ }
}
// TestLoadOfflineMissingCacheErrors checks Load's offline path errors
@@ -110,7 +116,7 @@ func TestLoadOfflineReadsCache(t *testing.T) {
func TestLoadOfflineMissingCacheErrors(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
- _, err := Load("2026-07-22", "pl", true)
+ _, _, err := Load("2026-07-22", "pl", true)
if err == nil {
t.Fatal("expected error for missing cache, got nil")
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index db0e8da..c58db6d 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -26,6 +26,7 @@ type Model struct {
verIdx int
date string
sections []liturgy.Section
+ dayInfo liturgy.DayInfo
scroll int
width int
height int
@@ -33,9 +34,11 @@ type Model struct {
err error
}
-// readingsMsg carries a successful fetch's sections back to Update.
+// readingsMsg carries a successful fetch's sections and DayInfo back to
+// Update.
type readingsMsg struct {
sections []liturgy.Section
+ dayInfo liturgy.DayInfo
}
// errMsg carries a failed fetch's error back to Update.
@@ -136,7 +139,7 @@ func (m Model) fetchCmd(refresh bool) tea.Cmd {
cfg := m.cfg
date := m.date
return func() tea.Msg {
- secs, err := readings.Load(cfg, readings.Options{
+ secs, info, err := readings.Load(cfg, readings.Options{
Date: date,
Refresh: refresh,
Offline: cfg.Offline,
@@ -145,7 +148,7 @@ func (m Model) fetchCmd(refresh bool) tea.Cmd {
if err != nil {
return errMsg{err}
}
- return readingsMsg{secs}
+ return readingsMsg{secs, info}
}
}
@@ -166,6 +169,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = false
m.err = nil
m.sections = msg.sections
+ m.dayInfo = msg.dayInfo
m.scroll = 0
return m, nil
@@ -203,10 +207,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.scroll = m.scrollTo(m.scroll - 1)
return m, nil
case " ":
- m.scroll = m.scrollTo(m.scroll + pageSize(m.height))
+ m.scroll = m.scrollTo(m.scroll + m.pageSize())
return m, nil
case "b":
- m.scroll = m.scrollTo(m.scroll - pageSize(m.height))
+ m.scroll = m.scrollTo(m.scroll - m.pageSize())
return m, nil
case "g":
m.scroll = 0
@@ -219,15 +223,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-// pageSize is how many reading lines fit between the header and footer bars
-// for a given terminal height; it falls back to a sane default before the
-// first tea.WindowSizeMsg arrives (height == 0).
-func pageSize(height int) int {
- const chrome = 4 // header + blank + footer + margin
- if height <= chrome {
+// headerLines is how many lines the top header block renders as: 1 (just
+// the "lectio DATE [version]" bar) or 2 when a day-info line (the
+// celebration name, optionally with its temporal Season) is shown beneath
+// it -- see dayInfoLine.
+func (m Model) headerLines() int {
+ if m.dayInfo.Name == "" {
+ return 1
+ }
+ return 2
+}
+
+// pageSize is how many reading lines fit between the header block and
+// footer bar for the model's current terminal height; it falls back to a
+// sane default before the first tea.WindowSizeMsg arrives (height == 0).
+func (m Model) pageSize() int {
+ chrome := m.headerLines() + 3 // header block + blank + footer + margin
+ if m.height <= chrome {
return 10
}
- return height - chrome
+ return m.height - chrome
}
// clampScroll keeps scroll within [0, total-visible] (never negative).
@@ -263,11 +278,12 @@ func (m Model) innerWidth() int {
// view can't scroll past the end -- keeping m.scroll bounded in Update, not
// merely clamped for display in View.
func (m Model) scrollTo(s int) int {
- return clampScroll(s, len(m.bodyLines(m.innerWidth())), pageSize(m.height))
+ return clampScroll(s, len(m.bodyLines(m.innerWidth())), m.pageSize())
}
-// View renders the header (date + active version label), the scrolling
-// reading, and the footer keybar.
+// View renders the header (date + active version label, plus a day-info
+// line when the source carries one), the scrolling reading, and the
+// footer keybar.
func (m Model) View() string {
w := m.width
if w <= 0 {
@@ -276,11 +292,14 @@ func (m Model) View() string {
innerW := m.innerWidth()
header := headerStyle.Width(w).Render(m.headerText())
+ if line := m.dayInfoLine(); line != "" {
+ header += "\n" + line
+ }
footer := footerStyle.Width(w).Render(i18n.Get(m.cfg.UILanguage).FooterKeys)
bodyLines := m.bodyLines(innerW)
- visible := pageSize(m.height)
+ visible := m.pageSize()
scroll := clampScroll(m.scroll, len(bodyLines), visible)
end := scroll + visible
if end > len(bodyLines) {
@@ -303,6 +322,23 @@ func (m Model) headerText() string {
return fmt.Sprintf("lectio %s [%s]", m.date, label)
}
+// dayInfoLine renders the day's celebration name (heading/accent style,
+// source-language, never translated -- like the readings/citations
+// themselves; see liturgy.DayInfo) with its temporal Season, if any,
+// appended in the dim citation style, as the header block's second line.
+// Empty when the active source yielded no DayInfo (m.dayInfo.Name == ""),
+// which is never an error -- the header is simply omitted.
+func (m Model) dayInfoLine() string {
+ if m.dayInfo.Name == "" {
+ return ""
+ }
+ line := headingStyle.Render(m.dayInfo.Name)
+ if m.dayInfo.Season != "" {
+ line += " " + citationStyle.Render(m.dayInfo.Season)
+ }
+ return line
+}
+
// bodyLines returns the styled, wrapped lines the reading pane scrolls
// through: a loading/error/empty notice, or each section's heading +
// render.GatherVersion blocks for the active version.
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 85a8175..330259d 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -113,6 +113,41 @@ func TestBodyLinesLocalisesHeading(t *testing.T) {
}
}
+// TestDayInfoLineShown checks the day-info line (celebration Name, plus
+// its temporal Season if any) is rendered under the top header bar when
+// the loaded source carries one, and that pageSize grows its chrome
+// allowance to account for the extra line.
+func TestDayInfoLineShown(t *testing.T) {
+ m := Model{
+ cfg: config.Config{UILanguage: "en"},
+ date: "2026-07-22",
+ dayInfo: liturgy.DayInfo{Name: "St. Mary Magdalene", Season: "Feria IV after VIII Sunday after Pentecost"},
+ }
+ out := m.View()
+ if !strings.Contains(out, "St. Mary Magdalene") {
+ t.Errorf("View() missing day-info name: %q", out)
+ }
+ if !strings.Contains(out, "Feria IV after VIII Sunday after Pentecost") {
+ t.Errorf("View() missing day-info season: %q", out)
+ }
+ if got := m.headerLines(); got != 2 {
+ t.Errorf("headerLines() = %d, want 2 when DayInfo.Name is set", got)
+ }
+}
+
+// TestDayInfoLineOmittedWhenEmpty checks the header block stays a single
+// line (headerLines() == 1) when the source yielded no DayInfo -- e.g. a
+// harvested-sigla offline load, which carries citations only.
+func TestDayInfoLineOmittedWhenEmpty(t *testing.T) {
+ m := Model{cfg: config.Config{UILanguage: "en"}, date: "2026-07-22"}
+ if line := m.dayInfoLine(); line != "" {
+ t.Errorf("dayInfoLine() = %q, want empty when DayInfo is zero", line)
+ }
+ if got := m.headerLines(); got != 1 {
+ t.Errorf("headerLines() = %d, want 1 when DayInfo is zero", got)
+ }
+}
+
// TestFooterKeysLocalised checks the footer keybar text follows
// cfg.UILanguage.
func TestFooterKeysLocalised(t *testing.T) {
diff --git a/internal/web/render.go b/internal/web/render.go
index 1afee1c..9e6280c 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -98,16 +98,45 @@ type ilLineView struct {
// spans so theme CSS can restyle them; verse/paragraph text is escaped by
// html/template. lang localises the version-column labels and each section
// heading's part-label word (render.LocalizeHeading, brief §3b); the
-// citation/verse text is never touched.
-func RenderReadings(secs []liturgy.Section, versions []string, lectionary, display, lang string) template.HTML {
+// citation/verse text is never touched. dayInfo is rendered once, ahead of
+// every layout's own sections, as the day's celebration header (see
+// renderDayInfo) -- never translated (source-language, like the readings/
+// citations), and simply omitted when the source carried none.
+func RenderReadings(secs []liturgy.Section, versions []string, lectionary, display, lang string, dayInfo liturgy.DayInfo) template.HTML {
+ var body template.HTML
switch display {
case "vertical":
- return renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary, lang))
+ body = renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary, lang))
case "interlinear":
- return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary, lang))
+ body = renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary, lang))
default:
- return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary, lang))
+ body = renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary, lang))
}
+ return renderDayInfo(dayInfo) + body
+}
+
+// renderDayInfo builds the "<p class="dayinfo">" header RenderReadings
+// prepends to the reading pane: the celebration Name as its heading-role
+// span, the temporal Season (if any) as its citation-role span. Reusing
+// those two role classes (rather than inventing new ones) means every
+// existing theme restyles it identically without a new CSS rule -- see
+// base.css/docs/THEMES.md. Returns "" (the header is simply omitted, never
+// an error) when the source yielded no DayInfo (info.Name == "").
+func renderDayInfo(info liturgy.DayInfo) template.HTML {
+ if info.Name == "" {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString(`<p class="dayinfo"><span class="heading">`)
+ b.WriteString(template.HTMLEscapeString(info.Name))
+ b.WriteString(`</span>`)
+ if info.Season != "" {
+ b.WriteString(` <span class="citation">`)
+ b.WriteString(template.HTMLEscapeString(info.Season))
+ b.WriteString(`</span>`)
+ }
+ b.WriteString(`</p>`)
+ return template.HTML(b.String())
}
// renderTemplate executes the named embedded template with data, degrading
diff --git a/internal/web/render_test.go b/internal/web/render_test.go
index b7f5b1e..de3c7c1 100644
--- a/internal/web/render_test.go
+++ b/internal/web/render_test.go
@@ -11,12 +11,52 @@ 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", "horizontal", "pl"))
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal", "pl", liturgy.DayInfo{}))
if !strings.Contains(html, "Ewangelia") || !strings.Contains(html, "class=") {
t.Errorf("reading pane missing heading/classes: %q", html[:min(200, len(html))])
}
}
+// TestRenderReadingsDayInfoHeader checks RenderReadings prepends a
+// "<p class="dayinfo">" header carrying the celebration Name (heading role)
+// and Season (citation role, muted) ahead of the reading sections, and that
+// it reuses the existing role classes rather than inventing new ones.
+func TestRenderReadingsDayInfoHeader(t *testing.T) {
+ secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
+ info := liturgy.DayInfo{Name: "Święto św. Marii Magdaleny", Colour: "white"}
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal", "pl", info))
+ if !strings.Contains(html, `class="dayinfo"`) {
+ t.Errorf("missing dayinfo header: %q", html[:min(300, len(html))])
+ }
+ if !strings.Contains(html, "Święto św. Marii Magdaleny") {
+ t.Errorf("dayinfo header missing the day name: %q", html[:min(300, len(html))])
+ }
+ if i := strings.Index(html, `class="dayinfo"`); i > strings.Index(html, "reading-section") && strings.Index(html, "reading-section") != -1 {
+ t.Errorf("dayinfo header should come before the reading sections: %q", html[:min(400, len(html))])
+ }
+}
+
+// TestRenderReadingsDayInfoSeason checks the Season (traditional lectionary
+// only) renders as a muted citation-role span alongside Name.
+func TestRenderReadingsDayInfoSeason(t *testing.T) {
+ secs := []liturgy.Section{{Heading: "Gospel", PartID: "evangelium"}}
+ info := liturgy.DayInfo{Name: "St. Mary Magdalene", Season: "Feria IV after VIII Sunday after Pentecost", Colour: "white"}
+ html := string(RenderReadings(secs, []string{"wuj"}, "traditional", "horizontal", "en", info))
+ if !strings.Contains(html, "Feria IV after VIII Sunday after Pentecost") {
+ t.Errorf("dayinfo header missing Season: %q", html[:min(400, len(html))])
+ }
+}
+
+// TestRenderReadingsNoDayInfoHeaderWhenEmpty checks the header is entirely
+// omitted (never an empty/error stub) when the source yielded no DayInfo.
+func TestRenderReadingsNoDayInfoHeaderWhenEmpty(t *testing.T) {
+ secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal", "pl", liturgy.DayInfo{}))
+ if strings.Contains(html, `class="dayinfo"`) {
+ t.Errorf("dayinfo header should be omitted when Name is empty: %q", html)
+ }
+}
+
func TestBuiltinThemes(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no user themes
for _, name := range []string{
@@ -45,7 +85,7 @@ func TestRenderReadingsEscapesScriptText(t *testing.T) {
PartID: "pierwsze_czytanie",
Paragraphs: [][]string{{"<script>alert(1)</script>"}},
}}
- html := string(RenderReadings(secs, []string{"bt"}, "new", "horizontal", "pl"))
+ html := string(RenderReadings(secs, []string{"bt"}, "new", "horizontal", "pl", liturgy.DayInfo{}))
if strings.Contains(html, "<script>alert(1)</script>") {
t.Errorf("raw <script> leaked into rendered output: %q", html)
}
@@ -56,7 +96,7 @@ 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", "pl"))
+ html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "vertical", "pl", liturgy.DayInfo{}))
if !strings.Contains(html, "display-vertical") {
t.Errorf("vertical output missing display-vertical container: %q", html[:min(300, len(html))])
}
@@ -70,7 +110,7 @@ func TestRenderReadingsVertical(t *testing.T) {
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", "pl"))
+ html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "interlinear", "pl", liturgy.DayInfo{}))
if !strings.Contains(html, "ilverse") {
t.Errorf("interlinear output missing ilverse: %q", html[:min(300, len(html))])
}
@@ -91,7 +131,7 @@ func TestRenderReadingsInterlinear(t *testing.T) {
func TestRenderReadingsInterlinearExcludesBT(t *testing.T) {
secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"bt", "vul"}, "new", "interlinear", "pl"))
+ html := string(RenderReadings(secs, []string{"bt", "vul"}, "new", "interlinear", "pl", liturgy.DayInfo{}))
if strings.Contains(html, "Biblia Tysiąclecia (niedziela.pl)") {
t.Errorf("interlinear output should substitute wuj for bt, not carry bt's label: %q", html[:min(300, len(html))])
}
@@ -102,7 +142,7 @@ func TestRenderReadingsInterlinearExcludesBT(t *testing.T) {
func TestRenderReadingsInterlinearNoVersifiedNote(t *testing.T) {
secs := []liturgy.Section{{Heading: "Bez odwołania", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "pl"))
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "pl", liturgy.DayInfo{}))
if strings.Contains(html, "ilverse") {
t.Errorf("expected no ilverse blocks when nothing resolves: %q", html)
}
@@ -115,7 +155,7 @@ func TestRenderReadingsInterlinearNoVersifiedNote(t *testing.T) {
// "no verses to align" note (finding §1) follows lang, not always Polish.
func TestRenderReadingsInterlinearNoVersifiedNoteLang(t *testing.T) {
secs := []liturgy.Section{{Heading: "Bez odwołania", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "en"))
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "en", liturgy.DayInfo{}))
if !strings.Contains(html, "(no verses to align)") {
t.Errorf("en no-verses note missing/wrong: %q", html)
}
@@ -130,7 +170,7 @@ func TestRenderReadingsInterlinearNoVersifiedNoteLang(t *testing.T) {
// citation stays exactly as scraped.
func TestRenderReadingsLocalizesEN(t *testing.T) {
secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"vul"}, "new", "horizontal", "en"))
+ html := string(RenderReadings(secs, []string{"vul"}, "new", "horizontal", "en", liturgy.DayInfo{}))
if !strings.Contains(html, "Gospel (J 20, 1. 11-18)") {
t.Errorf("en output missing localised heading: %q", html[:min(300, len(html))])
}
diff --git a/internal/web/server.go b/internal/web/server.go
index 110dae7..d8b451a 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -156,16 +156,17 @@ func resolveQuery(cfg config.Config, r *http.Request) (date, lectionary string,
// network-free set), or when lectionary is "traditional" (pl is the
// niedziela.pl modern scrape, meaningless for missalemeum) -- 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) {
+// always match what was actually loadable. dayInfo is the day's celebration
+// identity (see liturgy.DayInfo), zero when the source carried none.
+func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) (secs []liturgy.Section, dayInfo liturgy.DayInfo, effVersions []string, err error) {
cfg.Lectionary = lectionary
- versions = render.EffectiveVersions(versions, lectionary, cfg.Offline)
- secs, err := readings.Load(cfg, readings.Options{
+ effVersions = render.EffectiveVersions(versions, lectionary, cfg.Offline)
+ secs, dayInfo, err = readings.Load(cfg, readings.Options{
Date: date,
Offline: cfg.Offline,
All: all,
})
- return secs, versions, err
+ return secs, dayInfo, effVersions, err
}
// indexData is what templates/index.html ranges/branches over.
@@ -208,8 +209,8 @@ func indexHandler(cfg config.Config) http.HandlerFunc {
theme = cfg.WebTheme
}
- secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
- reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, err)
+ secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
+ reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, dayInfo, err)
// Check the boxes for the versions actually rendered (loadVersions),
// not the raw request: traditional/offline substitute pl->wuj, so the
@@ -258,8 +259,8 @@ func readingsHandler(cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date, lectionary, all, versions, display := resolveQuery(cfg, r)
- secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
- reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, err)
+ secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
+ reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, dayInfo, err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, string(reading))
@@ -271,14 +272,14 @@ func readingsHandler(cfg config.Config) http.HandlerFunc {
// paragraph, localised via lang -- 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, display, lang string, err error) template.HTML {
+func renderOrError(secs []liturgy.Section, versions []string, lectionary, display, lang string, dayInfo liturgy.DayInfo, 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">` + template.HTMLEscapeString(i18n.Get(lang).NoReadingsDay) + `</p>`)
}
- return RenderReadings(secs, versions, lectionary, display, lang)
+ return RenderReadings(secs, versions, lectionary, display, lang, dayInfo)
}
// themeCSSHandler serves one theme's stylesheet: the requested name, or
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 00da039..809ee59 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -50,6 +50,11 @@ func TestServer(t *testing.T) {
if !strings.Contains(body, `id="theme"`) {
t.Errorf("body missing theme <link>")
}
+ // Name is source-language (Polish), never translated, even
+ // though the surrounding chrome is English -- see RenderReadings.
+ if !strings.Contains(body, `class="dayinfo"`) || !strings.Contains(body, "Święto św. Marii Magdaleny") {
+ t.Errorf("body missing day-info header: %q", body)
+ }
})
t.Run("readings partial", func(t *testing.T) {
@@ -230,7 +235,7 @@ func TestServer(t *testing.T) {
// TestRenderOrErrorNoSectionsLang checks the "no readings at all for this
// day" fragment (finding §1) follows lang instead of always being Polish.
func TestRenderOrErrorNoSectionsLang(t *testing.T) {
- html := string(renderOrError(nil, nil, "new", "horizontal", "en", nil))
+ html := string(renderOrError(nil, nil, "new", "horizontal", "en", liturgy.DayInfo{}, nil))
if !strings.Contains(html, "no readings for this day") {
t.Errorf("en renderOrError(no secs) = %q, want it to contain %q", html, "no readings for this day")
}
@@ -238,7 +243,7 @@ func TestRenderOrErrorNoSectionsLang(t *testing.T) {
t.Errorf("en renderOrError(no secs) should not carry Polish wording: %q", html)
}
- html = string(renderOrError(nil, nil, "new", "horizontal", "pl", nil))
+ html = string(renderOrError(nil, nil, "new", "horizontal", "pl", liturgy.DayInfo{}, nil))
if !strings.Contains(html, "brak czytań na ten dzień") {
t.Errorf("pl renderOrError(no secs) = %q, want it to contain %q", html, "brak czytań na ten dzień")
}
diff --git a/internal/web/static/base.css b/internal/web/static/base.css
index b5ce9a5..1e30b1d 100644
--- a/internal/web/static/base.css
+++ b/internal/web/static/base.css
@@ -94,6 +94,23 @@ body.mono {
color: var(--fg);
}
+/* Day-info header (feast/day name + optional temporal season):
+ * RenderReadings prepends this once, ahead of every layout's own sections.
+ * It reuses the .heading/.citation role classes (rather than a bespoke
+ * rule) so every theme restyles it the same way it already restyles a
+ * reading section's own heading/citation -- no new colour rule needed. */
+.dayinfo {
+ margin: 0 0 var(--space-3);
+}
+
+.dayinfo .heading {
+ margin: 0 0 0.25rem;
+}
+
+.dayinfo .citation {
+ margin: 0;
+}
+
/* Reading pane */
.reading-section {
margin: 0 0 var(--space-4);