From c9f3bf46f0de09edb796e35f3dcff349febf75c9 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 10:32:28 +0200 Subject: dayinfo: show feast/day name + colour (modern niedziela + traditional missalemeum) in cli/tui/web; v0.5.0 --- internal/cli/cli.go | 33 +++++++++++++-- internal/cli/cli_test.go | 40 ++++++++++++++++++ internal/config/config.go | 2 +- internal/liturgy/fetch.go | 84 +++++++++++++++++++++++--------------- internal/liturgy/fetch_test.go | 16 ++++++-- internal/liturgy/parse.go | 53 ++++++++++++++++++++++++ internal/liturgy/parse_test.go | 53 ++++++++++++++++++++++++ internal/liturgy/section.go | 19 +++++++++ internal/liturgy/store.go | 2 +- internal/readings/readings.go | 20 +++++---- internal/readings/readings_test.go | 12 ++++-- internal/tradlit/parse_test.go | 53 +++++++++++++++++++++++- internal/tradlit/tradlit.go | 63 ++++++++++++++++++++++------ internal/tradlit/tradlit_test.go | 14 +++++-- internal/tui/tui.go | 68 ++++++++++++++++++++++-------- internal/tui/tui_test.go | 35 ++++++++++++++++ internal/web/render.go | 39 +++++++++++++++--- internal/web/render_test.go | 56 +++++++++++++++++++++---- internal/web/server.go | 23 ++++++----- internal/web/server_test.go | 9 +++- internal/web/static/base.css | 17 ++++++++ 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 " 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)

(.*?)

`) pRe = regexp.MustCompile(`(?s)

(.*?)

`) citationRe = regexp.MustCompile(`\((.+)\)\s*$`) + + // dayNamePRe matches every classed

...

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)

\s*(.*?)\s*

`) + + // dayColourRe matches niedziela.pl's "Kolor szat: " vestment-colour + // line, tolerating the / 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. `
`. 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

NAME

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: " 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

...

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 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("redesigned") + if info != (DayInfo{}) { + t.Errorf("ParseDayInfo(unrecognised) = %+v, want zero value", info) + } +} + func TestParseLayoutChange(t *testing.T) { if _, err := Parse("redesigned"); 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 "

" 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(`

`) + b.WriteString(template.HTMLEscapeString(info.Name)) + b.WriteString(``) + if info.Season != "" { + b.WriteString(` `) + b.WriteString(template.HTMLEscapeString(info.Season)) + b.WriteString(``) + } + b.WriteString(`

`) + 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 +// "

" 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{{""}}, }} - html := string(RenderReadings(secs, []string{"bt"}, "new", "horizontal", "pl")) + html := string(RenderReadings(secs, []string{"bt"}, "new", "horizontal", "pl", liturgy.DayInfo{})) if strings.Contains(html, "") { t.Errorf("raw