summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/cli/cli.go12
-rw-r--r--internal/cli/cli_test.go21
-rw-r--r--internal/i18n/i18n.go117
-rw-r--r--internal/i18n/i18n_test.go72
-rw-r--r--internal/render/render.go62
-rw-r--r--internal/render/render_test.go56
-rw-r--r--internal/tui/tui_test.go43
-rw-r--r--internal/web/render.go3
-rw-r--r--internal/web/render_test.go16
-rw-r--r--internal/web/server.go12
-rw-r--r--internal/web/server_test.go47
-rw-r--r--internal/web/templates/index.html2
12 files changed, 387 insertions, 76 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index b660878..a13f2f1 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -289,17 +289,19 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width
return 0
}
-// bannerFor builds the "<Gospel|Readings> — DATE" banner: the "readings"
-// word when every part is shown, "gospel" for the gospel-only default, both
-// localised via i18n.Get(lang) (lang="pl" reproduces ewangelia.py's original
-// Polish wording, just with "—" in place of "na").
+// 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
+// i18n.Get(lang) -- lang="pl" reproduces ewangelia.py's original Polish
+// wording exactly ("Ewangelia na DATE" / "Czytania na DATE"); lang="en"
+// gives "Gospel for DATE" / "Readings for DATE".
func bannerFor(lang string, all bool, date string) string {
ui := i18n.Get(lang)
word := ui.BannerGospel
if all {
word = ui.BannerReadings
}
- return word + " — " + date
+ return word + " " + ui.BannerConnective + " " + date
}
// renderSection formats one section as its heading (unless raw) followed by
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index de9220a..d0c8791 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -105,6 +105,27 @@ func TestLangBogus(t *testing.T) {
}
}
+// TestBannerForLang checks bannerFor's wording follows lang: pl reproduces
+// the pre-i18n Polish banner exactly ("Ewangelia na D" / "Czytania na D"),
+// en gives "Gospel for D" / "Readings for D".
+func TestBannerForLang(t *testing.T) {
+ cases := []struct {
+ lang string
+ all bool
+ want string
+ }{
+ {"pl", false, "Ewangelia na 2026-07-22"},
+ {"pl", true, "Czytania na 2026-07-22"},
+ {"en", false, "Gospel for 2026-07-22"},
+ {"en", true, "Readings for 2026-07-22"},
+ }
+ for _, c := range cases {
+ if got := bannerFor(c.lang, c.all, "2026-07-22"); got != c.want {
+ t.Errorf("bannerFor(%q, %v, ...) = %q, want %q", c.lang, c.all, got, c.want)
+ }
+ }
+}
+
// 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/i18n/i18n.go b/internal/i18n/i18n.go
index a5cb77b..4d034a5 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -19,17 +19,38 @@ type UI struct {
// keeping the citation untouched.
PartLabel map[string]string
- // TUI keybar and status messages.
+ // TUI keybar and status messages. NoReadingsFor and ErrorPrefix each
+ // carry their own single trailing space (the TUI concatenates a date or
+ // error string directly onto them, no separator added at the call site).
FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string
- // CLI banner label words: the banner is "<word> — <date>".
- BannerGospel, BannerReadings string
+ // CLI banner label words and connective: the banner is
+ // "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" /
+ // "Ewangelia na 2026-07-22").
+ BannerGospel, BannerReadings, BannerConnective string
// Web control labels.
Lectionary, OptModern, OptTraditional string
Parts, OptGospel, OptAll string
Layout, OptHorizontal, OptColumns string
OptInterlinear, Theme, Mono string
+
+ // NoInterlinearVerses is the web interlinear pane's note when no
+ // requested version could be interleaved for a section.
+ NoInterlinearVerses string
+
+ // NoReadingsDay is the web error fragment shown when readings.Load
+ // returns no sections at all for the requested date.
+ NoReadingsDay string
+
+ // NoVersion/NoVersionPartial/NoReference/NoReferenceErr are
+ // render.GatherVersion/GatherVerses' lookup/citation-failure blocks,
+ // rendered verbatim by cli/tui/web. NoVersion takes the version code;
+ // NoVersionPartial takes the version code and the comma-joined missing
+ // references; NoReferenceErr takes the underlying error (pl keeps %w so
+ // it still wraps, en uses %v -- the verb only controls
+ // errors.Unwrap-ability, both format identically via Error()).
+ NoVersion, NoVersionPartial, NoReference, NoReferenceErr string
}
// Get returns lang's chrome string set, falling back to English for
@@ -56,25 +77,32 @@ var enUI = UI{
"aklamacja": "Acclamation",
"ewangelia": "Gospel",
},
- FooterKeys: "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit",
- Loading: "loading…",
- NoReadingsFor: "no readings for ",
- ErrorPrefix: "error: ",
- ErrorHint: "change date (←/→) or refresh (r)",
- BannerGospel: "Gospel",
- BannerReadings: "Readings",
- Lectionary: "lectionary",
- OptModern: "modern",
- OptTraditional: "traditional",
- Parts: "parts",
- OptGospel: "Gospel",
- OptAll: "all parts",
- Layout: "layout",
- OptHorizontal: "horizontal",
- OptColumns: "columns",
- OptInterlinear: "interlinear",
- Theme: "theme",
- Mono: "mono",
+ FooterKeys: "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit",
+ Loading: "loading…",
+ NoReadingsFor: "no readings for ",
+ ErrorPrefix: "error: ",
+ ErrorHint: "change date (←/→) or refresh (r)",
+ BannerGospel: "Gospel",
+ BannerReadings: "Readings",
+ BannerConnective: "for",
+ Lectionary: "lectionary",
+ OptModern: "modern",
+ OptTraditional: "traditional",
+ Parts: "parts",
+ OptGospel: "Gospel",
+ OptAll: "all parts",
+ Layout: "layout",
+ OptHorizontal: "horizontal",
+ OptColumns: "columns",
+ OptInterlinear: "interlinear",
+ Theme: "theme",
+ Mono: "mono",
+ NoInterlinearVerses: "(no verses to align)",
+ NoReadingsDay: "no readings for this day",
+ NoVersion: "(not in %s)",
+ NoVersionPartial: "(not in %s: %s)",
+ NoReference: "(no reference)",
+ NoReferenceErr: "(no reference: %v)",
}
var plUI = UI{
@@ -92,23 +120,30 @@ var plUI = UI{
"aklamacja": "Aklamacja",
"ewangelia": "Ewangelia",
},
- FooterKeys: "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście",
- Loading: "ładowanie…",
- NoReadingsFor: "brak czytań na ",
- ErrorPrefix: "błąd: ",
- ErrorHint: "zmień datę (←/→) lub odśwież (r)",
- BannerGospel: "Ewangelia",
- BannerReadings: "Czytania",
- Lectionary: "lekcjonarz",
- OptModern: "nowy",
- OptTraditional: "tradycyjny",
- Parts: "zakres",
- OptGospel: "Ewangelia",
- OptAll: "wszystkie części",
- Layout: "układ",
- OptHorizontal: "poziomo",
- OptColumns: "kolumny",
- OptInterlinear: "interlinearnie",
- Theme: "motyw",
- Mono: "mono",
+ FooterKeys: "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście",
+ Loading: "ładowanie…",
+ NoReadingsFor: "brak czytań na ",
+ ErrorPrefix: "błąd: ",
+ ErrorHint: "zmień datę (←/→) lub odśwież (r)",
+ BannerGospel: "Ewangelia",
+ BannerReadings: "Czytania",
+ BannerConnective: "na",
+ Lectionary: "lekcjonarz",
+ OptModern: "nowy",
+ OptTraditional: "tradycyjny",
+ Parts: "zakres",
+ OptGospel: "Ewangelia",
+ OptAll: "wszystkie części",
+ Layout: "układ",
+ OptHorizontal: "poziomo",
+ OptColumns: "kolumny",
+ OptInterlinear: "interlinearnie",
+ Theme: "motyw",
+ Mono: "mono",
+ NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
+ NoReadingsDay: "brak czytań na ten dzień",
+ NoVersion: "(brak w „%s”)",
+ NoVersionPartial: "(brak w „%s”: %s)",
+ NoReference: "(brak odwołania)",
+ NoReferenceErr: "(brak odwołania: %w)",
}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
index 8c6f137..59771a8 100644
--- a/internal/i18n/i18n_test.go
+++ b/internal/i18n/i18n_test.go
@@ -1,6 +1,9 @@
package i18n
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestGetTheme(t *testing.T) {
if got := Get("pl").Theme; got != "motyw" {
@@ -56,12 +59,21 @@ func TestTUIStrings(t *testing.T) {
if en.Loading != "loading…" || pl.Loading != "ładowanie…" {
t.Errorf("Loading en=%q pl=%q", en.Loading, pl.Loading)
}
- if en.NoReadingsFor != "no readings for " || pl.NoReadingsFor != "brak czytań na " {
+ if en.NoReadingsFor != "no readings for " || pl.NoReadingsFor != "brak czytań na " {
t.Errorf("NoReadingsFor en=%q pl=%q", en.NoReadingsFor, pl.NoReadingsFor)
}
- if en.ErrorPrefix != "error: " || pl.ErrorPrefix != "błąd: " {
+ if en.ErrorPrefix != "error: " || pl.ErrorPrefix != "błąd: " {
t.Errorf("ErrorPrefix en=%q pl=%q", en.ErrorPrefix, pl.ErrorPrefix)
}
+ // Both carry exactly one trailing space: the TUI concatenates a date or
+ // error string directly onto them with no separator of its own (see
+ // internal/tui bodyLines: ui.NoReadingsFor+m.date, ui.ErrorPrefix+err).
+ if strings.HasSuffix(en.NoReadingsFor, " ") || strings.HasSuffix(pl.NoReadingsFor, " ") {
+ t.Errorf("NoReadingsFor has 2+ trailing spaces: en=%q pl=%q", en.NoReadingsFor, pl.NoReadingsFor)
+ }
+ if strings.HasSuffix(en.ErrorPrefix, " ") || strings.HasSuffix(pl.ErrorPrefix, " ") {
+ t.Errorf("ErrorPrefix has 2+ trailing spaces: en=%q pl=%q", en.ErrorPrefix, pl.ErrorPrefix)
+ }
if en.ErrorHint != "change date (←/→) or refresh (r)" || pl.ErrorHint != "zmień datę (←/→) lub odśwież (r)" {
t.Errorf("ErrorHint en=%q pl=%q", en.ErrorHint, pl.ErrorHint)
}
@@ -76,6 +88,60 @@ func TestBannerWords(t *testing.T) {
if en.BannerReadings != "Readings" || pl.BannerReadings != "Czytania" {
t.Errorf("BannerReadings en=%q pl=%q", en.BannerReadings, pl.BannerReadings)
}
+ if en.BannerConnective != "for" || pl.BannerConnective != "na" {
+ t.Errorf("BannerConnective en=%q pl=%q", en.BannerConnective, pl.BannerConnective)
+ }
+}
+
+// TestWebFragmentMessages covers the two web-only messages (interlinear
+// "no verses" note, "no readings at all for this day" error fragment) that
+// were unconditionally Polish before this fix.
+func TestWebFragmentMessages(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+ if en.NoInterlinearVerses != "(no verses to align)" {
+ t.Errorf("en.NoInterlinearVerses = %q", en.NoInterlinearVerses)
+ }
+ if pl.NoInterlinearVerses != "(brak wersetów do zestawienia interlinearnego)" {
+ t.Errorf("pl.NoInterlinearVerses = %q", pl.NoInterlinearVerses)
+ }
+ if en.NoReadingsDay != "no readings for this day" {
+ t.Errorf("en.NoReadingsDay = %q", en.NoReadingsDay)
+ }
+ if pl.NoReadingsDay != "brak czytań na ten dzień" {
+ t.Errorf("pl.NoReadingsDay = %q", pl.NoReadingsDay)
+ }
+}
+
+// TestLookupFailureMessages covers render.GatherVersion/GatherVerses/
+// resolveRef's four lookup/citation-failure templates.
+func TestLookupFailureMessages(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+
+ cases := []struct {
+ name, en, pl string
+ }{
+ {"NoVersion", en.NoVersion, pl.NoVersion},
+ {"NoVersionPartial", en.NoVersionPartial, pl.NoVersionPartial},
+ {"NoReference", en.NoReference, pl.NoReference},
+ {"NoReferenceErr", en.NoReferenceErr, pl.NoReferenceErr},
+ }
+ want := map[string][2]string{
+ "NoVersion": {"(not in %s)", "(brak w „%s”)"},
+ "NoVersionPartial": {"(not in %s: %s)", "(brak w „%s”: %s)"},
+ "NoReference": {"(no reference)", "(brak odwołania)"},
+ "NoReferenceErr": {"(no reference: %v)", "(brak odwołania: %w)"},
+ }
+ for _, c := range cases {
+ w := want[c.name]
+ if c.en != w[0] {
+ t.Errorf("en.%s = %q, want %q", c.name, c.en, w[0])
+ }
+ if c.pl != w[1] {
+ t.Errorf("pl.%s = %q, want %q", c.name, c.pl, w[1])
+ }
+ }
}
func TestWebControlLabels(t *testing.T) {
diff --git a/internal/render/render.go b/internal/render/render.go
index c5a2ab0..6bccc65 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -24,28 +24,45 @@ func versionLabel(version, lang string) string {
return version
}
+// modernPartOrder is the fixed, deterministic order LocalizeHeading tries
+// the known modern (niedziela.pl) pl part labels in -- a plain slice rather
+// than ranging over i18n.UI.PartLabel (a map, so Go randomises its
+// iteration order). None of the five labels is a prefix of another, so the
+// order never changes which one matches, only determinism.
+var modernPartOrder = []string{"pierwsze_czytanie", "drugie_czytanie", "psalm", "aklamacja", "ewangelia"}
+
// LocalizeHeading swaps a modern (niedziela.pl) section heading's leading
// label word for its lang translation, keeping the rest of the heading (the
// parenthetical citation, exactly as scraped) untouched: e.g.
-// "Ewangelia (J 20, 1. 11-18)" with partID "ewangelia" and lang "en" becomes
-// "Gospel (J 20, 1. 11-18)". It only ever localises the label word, never
-// the citation or the verse text.
+// "Ewangelia (J 20, 1. 11-18)" with lang "en" becomes "Gospel (J 20, 1.
+// 11-18)". It only ever localises the label word, never the citation or the
+// verse text.
+//
+// It matches against heading's own leading text, not partID: a split
+// (two-reading) feast day scrapes PartID="drugie_czytanie" onto a heading
+// that niedziela.pl still literally titles "1. czytanie ..." (its own
+// numbering quirk carries over from the undivided day), so looking up the
+// Polish label by partID and checking heading's prefix against only that
+// one label misses it. Instead every known pl label (modernPartOrder) is
+// tried against heading in turn; partID itself is unused -- kept in the
+// signature for callers, which all have it in hand already (sec.PartID).
//
-// It is a safe no-op (returns heading unchanged) unless all of: lang is
-// "en", partID names a known modern-lectionary part (see
-// internal/i18n.UI.PartLabel), and heading actually starts with that part's
-// Polish label -- which excludes traditional (missalemeum) headings, already
-// in the requested language, and anything unrecognised.
+// It is a safe no-op (returns heading unchanged) unless lang is "en" and
+// heading actually starts with one of the known pl labels -- which excludes
+// traditional (missalemeum) headings, already in the requested language,
+// and anything unrecognised.
func LocalizeHeading(heading, partID, lang string) string {
if lang != "en" {
return heading
}
- plLabel, ok := i18n.Get("pl").PartLabel[partID]
- if !ok || !strings.HasPrefix(heading, plLabel) {
- return heading
+ plUI, enUI := i18n.Get("pl"), i18n.Get("en")
+ for _, id := range modernPartOrder {
+ plLabel := plUI.PartLabel[id]
+ if strings.HasPrefix(heading, plLabel) {
+ return enUI.PartLabel[id] + heading[len(plLabel):]
+ }
}
- enLabel := i18n.Get(lang).PartLabel[partID]
- return enLabel + heading[len(plLabel):]
+ return heading
}
// versionSystem maps a bible version to the Psalter system bible.ToEnglishRef
@@ -83,14 +100,14 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string)
return label, gatherPL(sec)
}
- ref, err := resolveRef(version, sec, lectionary)
+ ref, err := resolveRef(version, sec, lectionary, lang)
if err != nil {
return label, []string{err.Error()}
}
verses, missing := bible.Lookup(version, ref)
if len(verses) == 0 {
- return label, []string{fmt.Sprintf("(brak w „%s”)", version)}
+ return label, []string{fmt.Sprintf(i18n.Get(lang).NoVersion, version)}
}
blocks = make([]string, 0, len(verses))
@@ -98,7 +115,7 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string)
blocks = append(blocks, fmt.Sprintf("%d:%d %s", v.Chapter, v.Verse, v.Text))
}
if len(missing) > 0 {
- blocks = append(blocks, fmt.Sprintf("(brak w „%s”: %s)", version, strings.Join(missing, ", ")))
+ blocks = append(blocks, fmt.Sprintf(i18n.Get(lang).NoVersionPartial, version, strings.Join(missing, ", ")))
}
return label, blocks
}
@@ -108,8 +125,11 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string)
// converted to English/kjv-style via bible.ToEnglishRef when
// lectionary=="new" (a "traditional" citation is already English-style and
// used as-is). Shared by GatherVersion and GatherVerses so both apply the
-// exact same resolution.
-func resolveRef(version string, sec liturgy.Section, lectionary string) (string, error) {
+// exact same resolution. lang selects the wording of the two failure
+// messages it can return (see internal/i18n.UI.NoReference/NoReferenceErr);
+// it never affects which reference is resolved.
+func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (string, error) {
+ ui := i18n.Get(lang)
citation := sec.Citation
if citation == "" {
if c, err := liturgy.ExtractCitation(sec.Heading); err == nil {
@@ -117,7 +137,7 @@ func resolveRef(version string, sec liturgy.Section, lectionary string) (string,
}
}
if citation == "" {
- return "", fmt.Errorf("(brak odwołania)")
+ return "", fmt.Errorf("%s", ui.NoReference)
}
if lectionary != "new" {
@@ -125,7 +145,7 @@ func resolveRef(version string, sec liturgy.Section, lectionary string) (string,
}
ref, err := bible.ToEnglishRef(citation, system(version))
if err != nil {
- return "", fmt.Errorf("(brak odwołania: %w)", err)
+ return "", fmt.Errorf(ui.NoReferenceErr, err)
}
return ref, nil
}
@@ -141,7 +161,7 @@ func GatherVerses(version string, sec liturgy.Section, lectionary, lang string)
return label, nil, false
}
- ref, err := resolveRef(version, sec, lectionary)
+ ref, err := resolveRef(version, sec, lectionary, lang)
if err != nil {
return label, nil, false
}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
index b016d6b..affaaf8 100644
--- a/internal/render/render_test.go
+++ b/internal/render/render_test.go
@@ -76,6 +76,56 @@ func TestCompareLabelLang(t *testing.T) {
}
}
+// TestGatherVersionNoVersionLang checks that the "(not in %s)" block
+// (bible.Lookup finding nothing at all) follows lang -- pl reproduces the
+// original Polish wording exactly, en uses internal/i18n's English wording.
+func TestGatherVersionNoVersionLang(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
+
+ _, blocks := GatherVersion("zzz", sec, "new", "pl")
+ if len(blocks) == 0 || blocks[0] != `(brak w „zzz”)` {
+ t.Errorf(`GatherVersion(..., "pl") blocks = %v, want [(brak w „zzz”)]`, blocks)
+ }
+
+ _, blocks = GatherVersion("zzz", sec, "new", "en")
+ if len(blocks) == 0 || blocks[0] != "(not in zzz)" {
+ t.Errorf(`GatherVersion(..., "en") blocks = %v, want [(not in zzz)]`, blocks)
+ }
+}
+
+// TestGatherVersionNoReferenceLang checks the "(no reference)" block (no
+// citation resolvable at all) follows lang.
+func TestGatherVersionNoReferenceLang(t *testing.T) {
+ sec := liturgy.Section{Heading: "Bez odwołania"}
+
+ _, blocks := GatherVersion("wuj", sec, "new", "pl")
+ if len(blocks) == 0 || blocks[0] != "(brak odwołania)" {
+ t.Errorf(`GatherVersion(..., "pl") blocks = %v, want [(brak odwołania)]`, blocks)
+ }
+
+ _, blocks = GatherVersion("wuj", sec, "new", "en")
+ if len(blocks) == 0 || blocks[0] != "(no reference)" {
+ t.Errorf(`GatherVersion(..., "en") blocks = %v, want [(no reference)]`, blocks)
+ }
+}
+
+// TestGatherVersionNoReferenceErrLang checks the "(no reference: ...)" block
+// (a citation that bible.ToEnglishRef fails to convert, e.g. an unrecognised
+// book) follows lang.
+func TestGatherVersionNoReferenceErrLang(t *testing.T) {
+ sec := liturgy.Section{Citation: "Xyz 1, 1-2"}
+
+ _, blocks := GatherVersion("wuj", sec, "new", "pl")
+ if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(brak odwołania: ") {
+ t.Errorf(`GatherVersion(..., "pl") blocks = %v, want prefix "(brak odwołania: "`, blocks)
+ }
+
+ _, blocks = GatherVersion("wuj", sec, "new", "en")
+ if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(no reference: ") {
+ t.Errorf(`GatherVersion(..., "en") blocks = %v, want prefix "(no reference: "`, blocks)
+ }
+}
+
func TestOfflineVersions(t *testing.T) {
got := OfflineVersions([]string{"pl", "wuj", "vul"})
for _, v := range got {
@@ -143,6 +193,12 @@ func TestLocalizeHeading(t *testing.T) {
{"unknown partID unchanged", "Coś innego (X 1)", "", "en", "Coś innego (X 1)"},
{"already-English heading unchanged (traditional lectionary, no pl prefix to match)", "Gospel (Luke 7:36-50)", "ewangelia", "en", "Gospel (Luke 7:36-50)"},
{"prefix mismatch unchanged", "Nieoczekiwany tytuł (J 1)", "ewangelia", "en", "Nieoczekiwany tytuł (J 1)"},
+ // Split (two-reading) feast day: niedziela.pl scrapes
+ // PartID="drugie_czytanie" onto a heading that still literally
+ // starts with "1. czytanie" (its own numbering quirk). Matching
+ // must follow the heading's actual text, not partID's label, so
+ // this localises to "1st reading", not staying Polish.
+ {"drugie_czytanie partID with a 1. czytanie heading (split feast day)", "1. czytanie (Dz 2, 14. 22-33)", "drugie_czytanie", "en", "1st reading (Dz 2, 14. 22-33)"},
}
for _, c := range cases {
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index baa5c05..60b473b 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1,6 +1,7 @@
package tui
import (
+ "errors"
"strings"
"testing"
@@ -51,6 +52,48 @@ func TestBodyLinesLocalisesMessages(t *testing.T) {
}
}
+// TestBodyLinesSingleSpaceConcatenation is a regression test for the
+// i18n-migration whitespace bug: i18n.UI.NoReadingsFor/ErrorPrefix must
+// carry exactly one trailing space, since bodyLines concatenates the date/
+// error string directly onto them with no separator of its own -- the
+// pre-i18n TUI code produced "błąd: " + err / "brak czytań na " + date (one
+// space), and the i18n fields regressed to two.
+func TestBodyLinesSingleSpaceConcatenation(t *testing.T) {
+ enEmpty := Model{cfg: config.Config{UILanguage: "en"}, date: "2026-07-22"}
+ lines := enEmpty.bodyLines(80)
+ if len(lines) == 0 || !strings.Contains(lines[0], "no readings for 2026-07-22") {
+ t.Errorf("en no-readings bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "no readings for 2026-07-22")
+ }
+ if strings.Contains(lines[0], "for 2026") {
+ t.Errorf("en no-readings bodyLines[0] = %q, has 2+ spaces before the date", lines[0])
+ }
+
+ plEmpty := Model{cfg: config.Config{UILanguage: "pl"}, date: "2026-07-22"}
+ lines = plEmpty.bodyLines(80)
+ if len(lines) == 0 || !strings.Contains(lines[0], "brak czytań na 2026-07-22") {
+ t.Errorf("pl no-readings bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "brak czytań na 2026-07-22")
+ }
+
+ errText := "boom"
+ enErr := Model{cfg: config.Config{UILanguage: "en"}, err: errors.New(errText)}
+ lines = enErr.bodyLines(80)
+ if len(lines) == 0 || !strings.Contains(lines[0], "error: "+errText) {
+ t.Errorf("en error bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "error: "+errText)
+ }
+ if strings.Contains(lines[0], "error: ") {
+ t.Errorf("en error bodyLines[0] = %q, has 2+ spaces after \"error:\"", lines[0])
+ }
+
+ plErr := Model{cfg: config.Config{UILanguage: "pl"}, err: errors.New(errText)}
+ lines = plErr.bodyLines(80)
+ if len(lines) == 0 || !strings.Contains(lines[0], "błąd: "+errText) {
+ t.Errorf("pl error bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "błąd: "+errText)
+ }
+ if strings.Contains(lines[0], "błąd: ") {
+ t.Errorf("pl error bodyLines[0] = %q, has 2+ spaces after \"błąd:\"", lines[0])
+ }
+}
+
// TestBodyLinesLocalisesHeading checks that a modern-lectionary section
// heading's label word follows cfg.UILanguage while the citation stays
// exactly as scraped (render.LocalizeHeading, brief §3b).
diff --git a/internal/web/render.go b/internal/web/render.go
index ca5bb89..208fc59 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -19,6 +19,7 @@ import (
"strings"
"github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
"github.com/lukaszkasprzak/lectio/internal/render"
)
@@ -203,7 +204,7 @@ func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary
}
if len(sets) == 0 {
- view.Note = "(brak wersetów do zestawienia interlinearnego)"
+ view.Note = i18n.Get(lang).NoInterlinearVerses
views = append(views, view)
continue
}
diff --git a/internal/web/render_test.go b/internal/web/render_test.go
index ea44ed2..494cdcd 100644
--- a/internal/web/render_test.go
+++ b/internal/web/render_test.go
@@ -106,6 +106,22 @@ func TestRenderReadingsInterlinearNoVersifiedNote(t *testing.T) {
if strings.Contains(html, "ilverse") {
t.Errorf("expected no ilverse blocks when nothing resolves: %q", html)
}
+ if !strings.Contains(html, "brak wersetów do zestawienia interlinearnego") {
+ t.Errorf("pl no-verses note missing/wrong: %q", html)
+ }
+}
+
+// TestRenderReadingsInterlinearNoVersifiedNoteLang checks the interlinear
+// "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"))
+ if !strings.Contains(html, "(no verses to align)") {
+ t.Errorf("en no-verses note missing/wrong: %q", html)
+ }
+ if strings.Contains(html, "brak wersetów") {
+ t.Errorf("en output should not carry the Polish no-verses note: %q", html)
+ }
}
// TestRenderReadingsLocalizesEN checks that lang="en" localises both the
diff --git a/internal/web/server.go b/internal/web/server.go
index 3b7102c..4c13576 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -188,6 +188,8 @@ type indexData struct {
// set from i18n.Get(cfg.UILanguage) -- index.html references its
// fields (e.g. {{.L.Lectionary}}) instead of hardcoded Polish text.
L i18n.UI
+ // Lang is cfg.UILanguage, rendered into <html lang="...">.
+ Lang string
}
type versionOpt struct {
@@ -245,6 +247,7 @@ func indexHandler(cfg config.Config) http.HandlerFunc {
Mono: queryBool(r, "mono", cfg.WebMono),
Reading: reading,
L: i18n.Get(cfg.UILanguage),
+ Lang: cfg.UILanguage,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -269,15 +272,16 @@ func readingsHandler(cfg config.Config) http.HandlerFunc {
}
// renderOrError returns RenderReadings' fragment, or (on a readings.Load
-// error) a small escaped error paragraph -- readings.Load errors are
-// expected in normal operation (an unpublished date, no network while
-// online, ...) so the pane should show them, not 500.
+// error, or no sections at all for the date) a small escaped error
+// 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 {
if err != nil {
return template.HTML(`<p class="error">` + template.HTMLEscapeString(err.Error()) + `</p>`)
}
if len(secs) == 0 {
- return template.HTML(`<p class="error">brak czytań na ten dzień</p>`)
+ return template.HTML(`<p class="error">` + template.HTMLEscapeString(i18n.Get(lang).NoReadingsDay) + `</p>`)
}
return RenderReadings(secs, versions, lectionary, display, lang)
}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 25d21f4..32ee412 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -227,6 +227,53 @@ 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))
+ 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")
+ }
+ if strings.Contains(html, "czyta") {
+ t.Errorf("en renderOrError(no secs) should not carry Polish wording: %q", html)
+ }
+
+ html = string(renderOrError(nil, nil, "new", "horizontal", "pl", 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ń")
+ }
+}
+
+// TestIndexHTMLLangAttribute checks index.html's <html lang="..."> follows
+// cfg.UILanguage (finding §6) instead of being hardcoded "pl".
+func TestIndexHTMLLangAttribute(t *testing.T) {
+ html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write(html)
+ }))
+ defer fixtureServer.Close()
+ liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia")
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ cfg := config.Default()
+ cfg.UILanguage = "en"
+ rec := httptest.NewRecorder()
+ NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
+ if !strings.Contains(rec.Body.String(), `<html lang="en">`) {
+ t.Errorf(`en index page missing <html lang="en">: %q`, rec.Body.String()[:min(400, rec.Body.Len())])
+ }
+
+ cfg.UILanguage = "pl"
+ rec = httptest.NewRecorder()
+ NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
+ if !strings.Contains(rec.Body.String(), `<html lang="pl">`) {
+ t.Errorf(`pl index page missing <html lang="pl">: %q`, rec.Body.String()[:min(400, rec.Body.Len())])
+ }
+}
+
// TestChooseListener exercises chooseListener's port-selection logic
// directly (no HTTP serving): port==0 prefers defaultWebPort (1099) and
// falls back to a free OS port when 1099 is taken, and a non-zero port is
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 8c9fd86..780224f 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -11,7 +11,7 @@
included too; they override "date" via hx-vals. The theme <select>
has no server round trip: it swaps the #theme <link>'s href directly. */}}
<!doctype html>
-<html lang="pl">
+<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">