aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 21:51:40 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 21:51:40 +0200
commit4c776396115c8120c3e1cb8fda993449fcdcd326 (patch)
tree0144fb325663dde063057a5ec568c59d4cbe79da
parentbfa8df7b36ccbd44703e0705d51a2ed553a23164 (diff)
downloadlectio-4c776396115c8120c3e1cb8fda993449fcdcd326.tar.gz
lectio-4c776396115c8120c3e1cb8fda993449fcdcd326.zip
i18n: ui_language config (default en) for UI chrome across cli/tui/web; version labels localised
-rw-r--r--internal/cli/cli.go33
-rw-r--r--internal/config/config.go20
-rw-r--r--internal/config/config.toml1
-rw-r--r--internal/config/config_test.go37
-rw-r--r--internal/i18n/i18n.go114
-rw-r--r--internal/i18n/i18n_test.go145
-rw-r--r--internal/render/render.go66
-rw-r--r--internal/render/render_test.go82
-rw-r--r--internal/tui/tui.go22
-rw-r--r--internal/tui/tui_test.go54
-rw-r--r--internal/web/render.go31
-rw-r--r--internal/web/render_test.go30
-rw-r--r--internal/web/server.go14
-rw-r--r--internal/web/server_test.go11
-rw-r--r--internal/web/templates/index.html24
15 files changed, 600 insertions, 84 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index e7a2439..b660878 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
"github.com/lukaszkasprzak/lectio/internal/readings"
"github.com/lukaszkasprzak/lectio/internal/render"
@@ -274,7 +275,7 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width
w := resolveWidth(width, false, stdout)
if !raw {
- banner := bannerFor(all, date)
+ banner := bannerFor(cfg.UILanguage, all, date)
fmt.Fprintln(stdout, banner)
fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w)))
fmt.Fprintln(stdout)
@@ -282,33 +283,39 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width
pieces := make([]string, 0, len(secs))
for _, sec := range secs {
- pieces = append(pieces, renderSection(sec, version, cfg.Lectionary, w, raw))
+ pieces = append(pieces, renderSection(sec, version, cfg.Lectionary, cfg.UILanguage, w, raw))
}
fmt.Fprintln(stdout, strings.Join(pieces, "\n\n"))
return 0
}
-// bannerFor mirrors ewangelia.py's banner text: "Czytania na D" when every
-// part is shown, "Ewangelia na D" for the gospel-only default.
-func bannerFor(all bool, date string) string {
+// 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").
+func bannerFor(lang string, all bool, date string) string {
+ ui := i18n.Get(lang)
+ word := ui.BannerGospel
if all {
- return "Czytania na " + date
+ word = ui.BannerReadings
}
- return "Ewangelia na " + date
+ return word + " — " + date
}
// renderSection formats one section as its heading (unless raw) followed by
-// render.GatherVersion's blocks, each wrapped to width.
-func renderSection(sec liturgy.Section, version, lectionary string, width int, raw bool) string {
+// render.GatherVersion's blocks, each wrapped to width. The heading's part
+// label is localised via render.LocalizeHeading (lang); the citation/verse
+// text is never touched.
+func renderSection(sec liturgy.Section, version, lectionary, lang string, width int, raw bool) string {
var lines []string
if !raw {
- lines = append(lines, sec.Heading)
+ lines = append(lines, render.LocalizeHeading(sec.Heading, sec.PartID, lang))
if sec.Subtitle != "" {
lines = append(lines, sec.Subtitle)
}
lines = append(lines, "")
}
- _, blocks := render.GatherVersion(version, sec, lectionary)
+ _, blocks := render.GatherVersion(version, sec, lectionary, lang)
for _, b := range blocks {
lines = append(lines, wrapText(b, width))
}
@@ -359,12 +366,12 @@ func renderCompare(cfg config.Config, list, date string, all, raw bool, width in
w := resolveWidth(width, true, stdout)
if !raw {
- banner := bannerFor(all, date)
+ banner := bannerFor(cfg.UILanguage, all, date)
fmt.Fprintln(stdout, banner)
fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w)))
fmt.Fprintln(stdout)
}
- fmt.Fprintln(stdout, render.Compare(secs, versions, w, cfg.Lectionary))
+ fmt.Fprintln(stdout, render.Compare(secs, versions, w, cfg.Lectionary, cfg.UILanguage))
return 0
}
diff --git a/internal/config/config.go b/internal/config/config.go
index e501cf8..dd81544 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -51,6 +51,23 @@ func NormalizeDisplay(display string) string {
return d
}
+// validUILanguages are the two UI chrome languages lectio understands.
+var validUILanguages = map[string]bool{
+ "en": true,
+ "pl": true,
+}
+
+// NormalizeUILanguage lower-cases lang and falls back to "en" when it is not
+// one of validUILanguages -- the same lenient style as NormalizeDisplay (no
+// error, just a safe default).
+func NormalizeUILanguage(lang string) string {
+ l := strings.ToLower(lang)
+ if !validUILanguages[l] {
+ return "en"
+ }
+ return l
+}
+
// Config holds lectio's user-configurable settings.
type Config struct {
SchemaVersion int `toml:"schema_version"`
@@ -61,6 +78,7 @@ type Config struct {
Width int `toml:"width"`
All bool `toml:"all"`
Offline bool `toml:"offline"`
+ UILanguage string `toml:"ui_language"`
WebTheme string `toml:"web_theme"`
WebPort int `toml:"web_port"`
WebDisplay string `toml:"web_display"`
@@ -91,6 +109,7 @@ func Default() Config {
Width: 0,
All: false,
Offline: false,
+ UILanguage: "en",
WebTheme: "transfiguration",
WebPort: 0,
WebDisplay: "horizontal",
@@ -162,6 +181,7 @@ func Load() (Config, error) {
return def, nil
}
cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay)
+ cfg.UILanguage = NormalizeUILanguage(cfg.UILanguage)
if err := validate(cfg); err != nil {
return Config{}, err
diff --git a/internal/config/config.toml b/internal/config/config.toml
index 3554435..c1f97db 100644
--- a/internal/config/config.toml
+++ b/internal/config/config.toml
@@ -6,6 +6,7 @@ default_version = "pl" # TUI start / `lectio show` default
width = 0 # CLI wrap width; 0 = detect terminal
all = false # default to all parts (true) or just the gospel (false)
offline = false # true = never fetch; read only harvested sigla + cache
+ui_language = "en" # interface language (labels/keybar/banner): "en" or "pl". Readings stay source-language.
web_theme = "transfiguration" # default lectio-web theme (built-in order/season name, or a user theme in ~/.config/lectio/themes/)
web_port = 0 # lectio-web port; 0 = try 1099, then any free port
web_display = "horizontal" # default lectio-web layout ("uklad"): "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index ff513ea..616f0cd 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -107,6 +107,43 @@ func TestWebDisplayNormalizesUnknown(t *testing.T) {
}
}
+func TestUILanguageDefault(t *testing.T) {
+ def := Default()
+ if def.UILanguage != "en" {
+ t.Errorf("UILanguage default wrong: got %q, want %q", def.UILanguage, "en")
+ }
+}
+
+func TestUILanguageLoadsPL(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
+ os.WriteFile(filepath.Join(dir, "lectio", "config.toml"),
+ []byte("ui_language = \"pl\"\n"), 0o644)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.UILanguage != "pl" {
+ t.Errorf("UILanguage = %q, want %q", cfg.UILanguage, "pl")
+ }
+}
+
+func TestUILanguageNormalizesUnknown(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
+ os.WriteFile(filepath.Join(dir, "lectio", "config.toml"),
+ []byte("ui_language = \"bogus\"\n"), 0o644)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.UILanguage != "en" {
+ t.Errorf("UILanguage = %q, want %q (normalized from bogus)", cfg.UILanguage, "en")
+ }
+}
+
func TestPartShown(t *testing.T) {
var empty Config
if !empty.PartShown("new", "psalm") {
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 0000000..a5cb77b
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,114 @@
+// Package i18n holds lectio's UI-chrome string tables (English and Polish)
+// for the ui_language config setting. It localises the chrome only --
+// version-column labels, the TUI keybar/messages, the CLI banner words, the
+// web control labels, and the modern-lectionary section-heading label words
+// -- never the scripture verse text or citation, which stay source-language.
+package i18n
+
+// UI is one language's complete set of chrome strings.
+type UI struct {
+ // Version names each bible version for column headers / prose, keyed by
+ // version code (pl, wuj, vul, grb, drb).
+ Version map[string]string
+
+ // PartLabel names each modern-lectionary section's heading label word,
+ // keyed by liturgy.Section.PartID (pierwsze_czytanie, psalm,
+ // drugie_czytanie, aklamacja, ewangelia). The pl entries are the exact
+ // prefixes niedziela.pl's scraped headings carry, used by
+ // render.LocalizeHeading to recognise and swap the label word while
+ // keeping the citation untouched.
+ PartLabel map[string]string
+
+ // TUI keybar and status messages.
+ FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string
+
+ // CLI banner label words: the banner is "<word> — <date>".
+ BannerGospel, BannerReadings string
+
+ // Web control labels.
+ Lectionary, OptModern, OptTraditional string
+ Parts, OptGospel, OptAll string
+ Layout, OptHorizontal, OptColumns string
+ OptInterlinear, Theme, Mono string
+}
+
+// Get returns lang's chrome string set, falling back to English for
+// anything other than "pl".
+func Get(lang string) UI {
+ if lang == "pl" {
+ return plUI
+ }
+ return enUI
+}
+
+var enUI = UI{
+ Version: map[string]string{
+ "pl": "Polish (niedziela.pl)",
+ "wuj": "Wujek (Polish)",
+ "vul": "Vulgate (Latin)",
+ "grb": "Greek",
+ "drb": "Douay-Rheims (English)",
+ },
+ PartLabel: map[string]string{
+ "pierwsze_czytanie": "1st reading",
+ "psalm": "Psalm",
+ "drugie_czytanie": "2nd reading",
+ "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",
+}
+
+var plUI = UI{
+ Version: map[string]string{
+ "pl": "Polski (niedziela.pl)",
+ "wuj": "Wujek (pol.)",
+ "vul": "Wulgata (lac.)",
+ "grb": "Grecki",
+ "drb": "Douay-Rheims (ang.)",
+ },
+ PartLabel: map[string]string{
+ "pierwsze_czytanie": "1. czytanie",
+ "psalm": "Psalm",
+ "drugie_czytanie": "2. czytanie",
+ "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",
+}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
new file mode 100644
index 0000000..8c6f137
--- /dev/null
+++ b/internal/i18n/i18n_test.go
@@ -0,0 +1,145 @@
+package i18n
+
+import "testing"
+
+func TestGetTheme(t *testing.T) {
+ if got := Get("pl").Theme; got != "motyw" {
+ t.Errorf(`Get("pl").Theme = %q, want "motyw"`, got)
+ }
+ if got := Get("en").Theme; got != "theme" {
+ t.Errorf(`Get("en").Theme = %q, want "theme"`, got)
+ }
+}
+
+func TestGetUnknownFallsBackToEnglish(t *testing.T) {
+ en := Get("en")
+ if got := Get("xx"); got.Theme != en.Theme || got.BannerGospel != en.BannerGospel {
+ t.Errorf(`Get("xx") = %+v, want the English set %+v`, got, en)
+ }
+ if got := Get(""); got.Theme != en.Theme || got.BannerGospel != en.BannerGospel {
+ t.Errorf(`Get("") = %+v, want the English set %+v`, got, en)
+ }
+}
+
+func TestVersionLabels(t *testing.T) {
+ cases := []struct {
+ lang, code, want string
+ }{
+ {"en", "pl", "Polish (niedziela.pl)"},
+ {"pl", "pl", "Polski (niedziela.pl)"},
+ {"en", "wuj", "Wujek (Polish)"},
+ {"pl", "wuj", "Wujek (pol.)"},
+ {"en", "vul", "Vulgate (Latin)"},
+ {"pl", "vul", "Wulgata (lac.)"},
+ {"en", "grb", "Greek"},
+ {"pl", "grb", "Grecki"},
+ {"en", "drb", "Douay-Rheims (English)"},
+ {"pl", "drb", "Douay-Rheims (ang.)"},
+ }
+ for _, c := range cases {
+ if got := Get(c.lang).Version[c.code]; got != c.want {
+ t.Errorf("Get(%q).Version[%q] = %q, want %q", c.lang, c.code, got, c.want)
+ }
+ }
+}
+
+func TestTUIStrings(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+
+ if en.FooterKeys != "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit" {
+ t.Errorf("en.FooterKeys = %q", en.FooterKeys)
+ }
+ if pl.FooterKeys != "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście" {
+ t.Errorf("pl.FooterKeys = %q", pl.FooterKeys)
+ }
+ 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 " {
+ t.Errorf("NoReadingsFor en=%q pl=%q", en.NoReadingsFor, pl.NoReadingsFor)
+ }
+ if en.ErrorPrefix != "error: " || pl.ErrorPrefix != "błąd: " {
+ t.Errorf("ErrorPrefix 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)
+ }
+}
+
+func TestBannerWords(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+ if en.BannerGospel != "Gospel" || pl.BannerGospel != "Ewangelia" {
+ t.Errorf("BannerGospel en=%q pl=%q", en.BannerGospel, pl.BannerGospel)
+ }
+ if en.BannerReadings != "Readings" || pl.BannerReadings != "Czytania" {
+ t.Errorf("BannerReadings en=%q pl=%q", en.BannerReadings, pl.BannerReadings)
+ }
+}
+
+func TestWebControlLabels(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+
+ cases := []struct {
+ name, en, pl string
+ }{
+ {"Lectionary", en.Lectionary, pl.Lectionary},
+ {"OptModern", en.OptModern, pl.OptModern},
+ {"OptTraditional", en.OptTraditional, pl.OptTraditional},
+ {"Parts", en.Parts, pl.Parts},
+ {"OptGospel", en.OptGospel, pl.OptGospel},
+ {"OptAll", en.OptAll, pl.OptAll},
+ {"Layout", en.Layout, pl.Layout},
+ {"OptHorizontal", en.OptHorizontal, pl.OptHorizontal},
+ {"OptColumns", en.OptColumns, pl.OptColumns},
+ {"OptInterlinear", en.OptInterlinear, pl.OptInterlinear},
+ {"Theme", en.Theme, pl.Theme},
+ {"Mono", en.Mono, pl.Mono},
+ }
+ want := map[string][2]string{
+ "Lectionary": {"lectionary", "lekcjonarz"},
+ "OptModern": {"modern", "nowy"},
+ "OptTraditional": {"traditional", "tradycyjny"},
+ "Parts": {"parts", "zakres"},
+ "OptGospel": {"Gospel", "Ewangelia"},
+ "OptAll": {"all parts", "wszystkie części"},
+ "Layout": {"layout", "układ"},
+ "OptHorizontal": {"horizontal", "poziomo"},
+ "OptColumns": {"columns", "kolumny"},
+ "OptInterlinear": {"interlinear", "interlinearnie"},
+ "Theme": {"theme", "motyw"},
+ "Mono": {"mono", "mono"},
+ }
+ 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 TestModernPartLabels(t *testing.T) {
+ en := Get("en")
+ pl := Get("pl")
+
+ want := map[string][2]string{
+ "pierwsze_czytanie": {"1. czytanie", "1st reading"},
+ "psalm": {"Psalm", "Psalm"},
+ "drugie_czytanie": {"2. czytanie", "2nd reading"},
+ "aklamacja": {"Aklamacja", "Acclamation"},
+ "ewangelia": {"Ewangelia", "Gospel"},
+ }
+ for partID, w := range want {
+ if got := pl.PartLabel[partID]; got != w[0] {
+ t.Errorf("pl.PartLabel[%q] = %q, want %q", partID, got, w[0])
+ }
+ if got := en.PartLabel[partID]; got != w[1] {
+ t.Errorf("en.PartLabel[%q] = %q, want %q", partID, got, w[1])
+ }
+ }
+}
diff --git a/internal/render/render.go b/internal/render/render.go
index b8d778c..c5a2ab0 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -9,17 +9,43 @@ import (
"strings"
"github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
)
-// versionLabels names each version for column headers / prose. Ported
-// verbatim from ewangelia.py VERSION_LABELS.
-var versionLabels = map[string]string{
- "pl": "Polski (niedziela.pl)",
- "wuj": "Wujek (pol.)",
- "vul": "Wulgata (lac.)",
- "grb": "Grecki",
- "drb": "Douay-Rheims (ang.)",
+// versionLabel returns version's column-header/prose label in lang (see
+// internal/i18n.Get), falling back to the bare version code if lang has no
+// entry for it. lang="pl" reproduces ewangelia.py's original VERSION_LABELS
+// exactly.
+func versionLabel(version, lang string) string {
+ if l, ok := i18n.Get(lang).Version[version]; ok {
+ return l
+ }
+ return version
+}
+
+// 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.
+//
+// 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.
+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
+ }
+ enLabel := i18n.Get(lang).PartLabel[partID]
+ return enLabel + heading[len(plLabel):]
}
// versionSystem maps a bible version to the Psalter system bible.ToEnglishRef
@@ -49,8 +75,10 @@ const incipit = "Słowa Ewangelii"
// lectionary selects how sec.Citation is read: "new" (modern/niedziela.pl)
// citations are Polish and go through bible.ToEnglishRef; "traditional"
// (missalemeum) citations are already English kjv-style and are used as-is.
-func GatherVersion(version string, sec liturgy.Section, lectionary string) (label string, blocks []string) {
- label = versionLabels[version]
+// lang selects the UI chrome language the label comes from (see
+// internal/i18n); it never affects the verse text/citation itself.
+func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) (label string, blocks []string) {
+ label = versionLabel(version, lang)
if version == "pl" {
return label, gatherPL(sec)
}
@@ -105,9 +133,10 @@ func resolveRef(version string, sec liturgy.Section, lectionary string) (string,
// GatherVerses returns one version's verses for a section as raw bible.Verse
// structs (for column/interlinear alignment). versified is false for "pl"
// (paragraph text, no verse numbers) and on any resolution/lookup failure --
-// callers fall back to GatherVersion's string blocks for those.
-func GatherVerses(version string, sec liturgy.Section, lectionary string) (label string, verses []bible.Verse, versified bool) {
- label = versionLabels[version]
+// callers fall back to GatherVersion's string blocks for those. lang selects
+// the UI chrome language the label comes from, same as GatherVersion.
+func GatherVerses(version string, sec liturgy.Section, lectionary, lang string) (label string, verses []bible.Verse, versified bool) {
+ label = versionLabel(version, lang)
if version == "pl" {
return label, nil, false
}
@@ -177,16 +206,17 @@ func OfflineVersions(versions []string) []string {
}
// Compare lays the versions of one reading section out as parallel columns,
-// side by side, wrapped to fit width. Ports render_compare.
-func Compare(secs []liturgy.Section, versions []string, width int, lectionary string) string {
+// side by side, wrapped to fit width. Ports render_compare. lang selects the
+// column-header label language (see GatherVersion).
+func Compare(secs []liturgy.Section, versions []string, width int, lectionary, lang string) string {
var out []string
for _, sec := range secs {
- out = append(out, compareSection(sec, versions, width, lectionary))
+ out = append(out, compareSection(sec, versions, width, lectionary, lang))
}
return strings.Join(out, "\n\n")
}
-func compareSection(sec liturgy.Section, versions []string, width int, lectionary string) string {
+func compareSection(sec liturgy.Section, versions []string, width int, lectionary, lang string) string {
type column struct {
label string
lines []string
@@ -205,7 +235,7 @@ func compareSection(sec liturgy.Section, versions []string, width int, lectionar
cols := make([]column, 0, n)
height := 0
for _, v := range versions {
- label, blocks := GatherVersion(v, sec, lectionary)
+ label, blocks := GatherVersion(v, sec, lectionary, lang)
var lines []string
for _, b := range blocks {
lines = append(lines, strings.Split(wrap(b, w), "\n")...)
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
index 1b338a0..b016d6b 100644
--- a/internal/render/render_test.go
+++ b/internal/render/render_test.go
@@ -12,7 +12,7 @@ func TestGatherPLDedup(t *testing.T) {
Heading: "Psalm (Ps 1)",
Paragraphs: [][]string{{"stanza one"}, {"refrain"}, {"stanza two"}, {"refrain"}},
}
- _, blocks := GatherVersion("pl", sec, "new")
+ _, blocks := GatherVersion("pl", sec, "new", "pl")
n := 0
for _, b := range blocks {
if b == "refrain" {
@@ -26,7 +26,7 @@ func TestGatherPLDedup(t *testing.T) {
func TestGatherBible(t *testing.T) {
sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
- label, blocks := GatherVersion("wuj", sec, "new")
+ label, blocks := GatherVersion("wuj", sec, "new", "pl")
if !strings.Contains(label, "Wujek") {
t.Errorf("label = %q", label)
}
@@ -37,12 +37,45 @@ func TestGatherBible(t *testing.T) {
func TestGatherTraditional(t *testing.T) {
sec := liturgy.Section{Citation: "Luke 7:36-50"}
- _, blocks := GatherVersion("vul", sec, "traditional")
+ _, blocks := GatherVersion("vul", sec, "traditional", "pl")
if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "7:36") {
t.Errorf("first block = %q", blocks)
}
}
+// TestGatherVersionLabelLang checks that the label's language follows the
+// lang parameter -- pl reproduces today's Polish labels exactly, en gives
+// the English set from internal/i18n.
+func TestGatherVersionLabelLang(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
+
+ label, _ := GatherVersion("vul", sec, "new", "pl")
+ if label != "Wulgata (lac.)" {
+ t.Errorf(`GatherVersion(..., "pl") label = %q, want "Wulgata (lac.)"`, label)
+ }
+
+ label, _ = GatherVersion("vul", sec, "new", "en")
+ if label != "Vulgate (Latin)" {
+ t.Errorf(`GatherVersion(..., "en") label = %q, want "Vulgate (Latin)"`, label)
+ }
+}
+
+// TestCompareLabelLang checks Compare's column-header row follows lang,
+// same as GatherVersion (which it wraps via compareSection).
+func TestCompareLabelLang(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
+
+ out := Compare([]liturgy.Section{sec}, []string{"vul"}, 80, "new", "pl")
+ if !strings.Contains(out, "Wulgata (lac.)") {
+ t.Errorf(`Compare(..., "pl") = %q, want it to contain "Wulgata (lac.)"`, out)
+ }
+
+ out = Compare([]liturgy.Section{sec}, []string{"vul"}, 80, "new", "en")
+ if !strings.Contains(out, "Vulgate (Latin)") {
+ t.Errorf(`Compare(..., "en") = %q, want it to contain "Vulgate (Latin)"`, out)
+ }
+}
+
func TestOfflineVersions(t *testing.T) {
got := OfflineVersions([]string{"pl", "wuj", "vul"})
for _, v := range got {
@@ -54,7 +87,7 @@ func TestOfflineVersions(t *testing.T) {
func TestGatherVersesBible(t *testing.T) {
sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
- label, verses, versified := GatherVerses("wuj", sec, "new")
+ label, verses, versified := GatherVerses("wuj", sec, "new", "pl")
if !strings.Contains(label, "Wujek") {
t.Errorf("label = %q", label)
}
@@ -74,7 +107,7 @@ func TestGatherVersesPL(t *testing.T) {
Heading: "Psalm (Ps 1)",
Paragraphs: [][]string{{"stanza one"}},
}
- _, verses, versified := GatherVerses("pl", sec, "new")
+ _, verses, versified := GatherVerses("pl", sec, "new", "pl")
if versified {
t.Error("versified = true, want false for pl (paragraph text, no verse numbers)")
}
@@ -82,3 +115,42 @@ func TestGatherVersesPL(t *testing.T) {
t.Errorf("verses = %+v, want nil for pl", verses)
}
}
+
+// TestGatherVersesLabelLang checks GatherVerses' label also follows lang,
+// same as GatherVersion.
+func TestGatherVersesLabelLang(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
+ label, _, _ := GatherVerses("vul", sec, "new", "en")
+ if label != "Vulgate (Latin)" {
+ t.Errorf(`GatherVerses(..., "en") label = %q, want "Vulgate (Latin)"`, label)
+ }
+}
+
+// TestLocalizeHeading covers render.LocalizeHeading's part-label swap
+// (brief §3b): the pl label prefix of a modern (niedziela.pl) heading is
+// replaced by its en label, the citation kept exactly as scraped, and every
+// other case (pl, unknown/traditional partID, prefix mismatch) is a safe
+// no-op that returns heading unchanged.
+func TestLocalizeHeading(t *testing.T) {
+ cases := []struct {
+ name, heading, partID, lang, want string
+ }{
+ {"gospel en", "Ewangelia (J 20, 1. 11-18)", "ewangelia", "en", "Gospel (J 20, 1. 11-18)"},
+ {"first reading en", "1. czytanie (Dz 2, 14. 22-33)", "pierwsze_czytanie", "en", "1st reading (Dz 2, 14. 22-33)"},
+ {"psalm en", "Psalm (Ps 15)", "psalm", "en", "Psalm (Ps 15)"},
+ {"acclamation en", "Aklamacja (Alleluja)", "aklamacja", "en", "Acclamation (Alleluja)"},
+ {"pl unchanged", "Ewangelia (J 20, 1. 11-18)", "ewangelia", "pl", "Ewangelia (J 20, 1. 11-18)"},
+ {"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)"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := LocalizeHeading(c.heading, c.partID, c.lang)
+ if got != c.want {
+ t.Errorf("LocalizeHeading(%q, %q, %q) = %q, want %q", c.heading, c.partID, c.lang, got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 1e3b948..bcfb3c1 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -13,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
"github.com/lukaszkasprzak/lectio/internal/readings"
"github.com/lukaszkasprzak/lectio/internal/render"
@@ -42,8 +43,6 @@ type errMsg struct {
err error
}
-const footerKeys = "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście"
-
// New builds the initial model: cfg.Offline drops "pl" from the version
// list (render.OfflineVersions), the active version starts at
// cfg.DefaultVersion (falling back to the first version if not found, or
@@ -256,7 +255,7 @@ func (m Model) View() string {
}
header := headerStyle.Width(w).Render(m.headerText())
- footer := footerStyle.Width(w).Render(footerKeys)
+ footer := footerStyle.Width(w).Render(i18n.Get(m.cfg.UILanguage).FooterKeys)
bodyLines := m.bodyLines(innerW)
@@ -276,7 +275,7 @@ func (m Model) View() string {
func (m Model) headerText() string {
label := m.version()
if len(m.sections) > 0 {
- if l, _ := render.GatherVersion(m.version(), m.sections[0], m.cfg.Lectionary); l != "" {
+ if l, _ := render.GatherVersion(m.version(), m.sections[0], m.cfg.Lectionary, m.cfg.UILanguage); l != "" {
label = l
}
}
@@ -287,17 +286,19 @@ func (m Model) headerText() string {
// through: a loading/error/empty notice, or each section's heading +
// render.GatherVersion blocks for the active version.
func (m Model) bodyLines(w int) []string {
+ ui := i18n.Get(m.cfg.UILanguage)
+
switch {
case m.err != nil:
return []string{
- errStyle.Render("błąd: " + m.err.Error()),
+ errStyle.Render(ui.ErrorPrefix + m.err.Error()),
"",
- citationStyle.Render("zmień datę (←/→) lub odśwież (r)"),
+ citationStyle.Render(ui.ErrorHint),
}
case m.loading:
- return []string{citationStyle.Render("ładowanie…")}
+ return []string{citationStyle.Render(ui.Loading)}
case len(m.sections) == 0:
- return []string{citationStyle.Render("brak czytań na " + m.date)}
+ return []string{citationStyle.Render(ui.NoReadingsFor + m.date)}
}
ver := m.version()
@@ -306,13 +307,14 @@ func (m Model) bodyLines(w int) []string {
if i > 0 {
lines = append(lines, "")
}
- lines = append(lines, headingStyle.Render(sec.Heading))
+ heading := render.LocalizeHeading(sec.Heading, sec.PartID, m.cfg.UILanguage)
+ lines = append(lines, headingStyle.Render(heading))
if sec.Subtitle != "" {
lines = append(lines, citationStyle.Render(sec.Subtitle))
}
lines = append(lines, "")
- _, blocks := render.GatherVersion(ver, sec, m.cfg.Lectionary)
+ _, blocks := render.GatherVersion(ver, sec, m.cfg.Lectionary, m.cfg.UILanguage)
isPsalm := sec.PartID == "psalm"
for bi, b := range blocks {
refrain := isPsalm && bi == 0
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 2aa7b84..baa5c05 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1,9 +1,11 @@
package tui
import (
+ "strings"
"testing"
"github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
)
func TestVersionCycle(t *testing.T) {
@@ -29,3 +31,55 @@ func TestOfflineDropsPL(t *testing.T) {
}
}
}
+
+// TestBodyLinesLocalisesMessages checks that the loading/no-readings/error
+// messages follow cfg.UILanguage.
+func TestBodyLinesLocalisesMessages(t *testing.T) {
+ en := Model{cfg: config.Config{UILanguage: "en"}, loading: true}
+ if lines := en.bodyLines(80); len(lines) == 0 || !strings.Contains(lines[0], "loading") {
+ t.Errorf("en loading bodyLines = %v, want it to contain %q", lines, "loading")
+ }
+
+ pl := Model{cfg: config.Config{UILanguage: "pl"}, loading: true}
+ if lines := pl.bodyLines(80); len(lines) == 0 || !strings.Contains(lines[0], "ładowanie") {
+ t.Errorf("pl loading bodyLines = %v, want it to contain %q", lines, "ładowanie")
+ }
+
+ enEmpty := Model{cfg: config.Config{UILanguage: "en"}, date: "2026-07-22"}
+ if lines := enEmpty.bodyLines(80); len(lines) == 0 || !strings.Contains(lines[0], "no readings for") {
+ t.Errorf("en no-readings bodyLines = %v, want it to contain %q", lines, "no readings for")
+ }
+}
+
+// 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).
+func TestBodyLinesLocalisesHeading(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}
+
+ en := Model{cfg: config.Config{UILanguage: "en", Lectionary: "new"}, sections: []liturgy.Section{sec}}
+ body := strings.Join(en.bodyLines(80), "\n")
+ if !strings.Contains(body, "Gospel (J 20, 1. 11-18)") {
+ t.Errorf("en bodyLines missing localised heading: %q", body)
+ }
+
+ pl := Model{cfg: config.Config{UILanguage: "pl", Lectionary: "new"}, sections: []liturgy.Section{sec}}
+ body = strings.Join(pl.bodyLines(80), "\n")
+ if !strings.Contains(body, "Ewangelia (J 20, 1. 11-18)") {
+ t.Errorf("pl bodyLines missing unchanged heading: %q", body)
+ }
+}
+
+// TestFooterKeysLocalised checks the footer keybar text follows
+// cfg.UILanguage.
+func TestFooterKeysLocalised(t *testing.T) {
+ en := Model{cfg: config.Config{UILanguage: "en"}, date: "2026-07-22"}
+ if out := en.View(); !strings.Contains(out, "q quit") {
+ t.Errorf("en View() footer missing %q: %q", "q quit", out)
+ }
+
+ pl := Model{cfg: config.Config{UILanguage: "pl"}, date: "2026-07-22"}
+ if out := pl.View(); !strings.Contains(out, "q wyjście") {
+ t.Errorf("pl View() footer missing %q: %q", "q wyjście", out)
+ }
+}
diff --git a/internal/web/render.go b/internal/web/render.go
index 63c1da4..ca5bb89 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -95,15 +95,17 @@ type ilLineView struct {
// In every mode, heading, citation (subtitle), verse-number and refrain
// text are wrapped in class="heading|citation|vnum|refrain|version-label"
// spans so theme CSS can restyle them; verse/paragraph text is escaped by
-// html/template.
-func RenderReadings(secs []liturgy.Section, versions []string, lectionary, display string) template.HTML {
+// 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 {
switch display {
case "vertical":
- return renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary))
+ return renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary, lang))
case "interlinear":
- return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary))
+ return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary, lang))
default:
- return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary))
+ return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary, lang))
}
}
@@ -122,15 +124,17 @@ func renderTemplate(name string, data any) template.HTML {
// buildColumnViews gathers each section's per-version columns via
// render.GatherVersion -- the shared data both the "horizontal"
// (readings.html) and "vertical" (readings-vertical.html) templates range
-// over; only the surrounding markup differs between the two layouts.
-func buildColumnViews(secs []liturgy.Section, versions []string, lectionary string) []sectionView {
+// over; only the surrounding markup differs between the two layouts. lang
+// localises the column labels and the heading's part-label word (see
+// RenderReadings).
+func buildColumnViews(secs []liturgy.Section, versions []string, lectionary, lang string) []sectionView {
views := make([]sectionView, 0, len(secs))
for _, sec := range secs {
isPsalm := sec.PartID == "psalm"
cols := make([]columnView, 0, len(versions))
for _, v := range versions {
- label, blocks := render.GatherVersion(v, sec, lectionary)
+ label, blocks := render.GatherVersion(v, sec, lectionary, lang)
bviews := make([]blockView, 0, len(blocks))
for bi, b := range blocks {
@@ -148,7 +152,7 @@ func buildColumnViews(secs []liturgy.Section, versions []string, lectionary stri
}
views = append(views, sectionView{
- Heading: sec.Heading,
+ Heading: render.LocalizeHeading(sec.Heading, sec.PartID, lang),
Subtitle: sec.Subtitle,
PartID: sec.PartID,
Columns: cols,
@@ -175,8 +179,9 @@ func interlinearVersions(versions []string) []string {
// that version's order (stable, no duplicates). A version that comes back
// unversified (a bible lookup miss) is simply skipped -- the remaining
// versions still render. A section where nothing could be interleaved gets
-// a short escaped Note instead of an empty Verses list.
-func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary string) []ilSectionView {
+// a short escaped Note instead of an empty Verses list. lang localises the
+// per-version labels and the heading's part-label word (see RenderReadings).
+func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary, lang string) []ilSectionView {
mapped := interlinearVersions(versions)
type verseSet struct {
@@ -186,11 +191,11 @@ func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary
views := make([]ilSectionView, 0, len(secs))
for _, sec := range secs {
- view := ilSectionView{Heading: sec.Heading, Subtitle: sec.Subtitle, PartID: sec.PartID}
+ view := ilSectionView{Heading: render.LocalizeHeading(sec.Heading, sec.PartID, lang), Subtitle: sec.Subtitle, PartID: sec.PartID}
var sets []verseSet
for _, v := range mapped {
- label, verses, versified := render.GatherVerses(v, sec, lectionary)
+ label, verses, versified := render.GatherVerses(v, sec, lectionary, lang)
if !versified {
continue
}
diff --git a/internal/web/render_test.go b/internal/web/render_test.go
index 0e64168..ea44ed2 100644
--- a/internal/web/render_test.go
+++ b/internal/web/render_test.go
@@ -11,7 +11,7 @@ import (
func TestRenderReadings(t *testing.T) {
secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal"))
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal", "pl"))
if !strings.Contains(html, "Ewangelia") || !strings.Contains(html, "class=") {
t.Errorf("reading pane missing heading/classes: %q", html[:min(200, len(html))])
}
@@ -45,7 +45,7 @@ func TestRenderReadingsEscapesScriptText(t *testing.T) {
PartID: "pierwsze_czytanie",
Paragraphs: [][]string{{"<script>alert(1)</script>"}},
}}
- html := string(RenderReadings(secs, []string{"pl"}, "new", "horizontal"))
+ html := string(RenderReadings(secs, []string{"pl"}, "new", "horizontal", "pl"))
if strings.Contains(html, "<script>alert(1)</script>") {
t.Errorf("raw <script> leaked into rendered output: %q", html)
}
@@ -56,7 +56,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"))
+ html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "vertical", "pl"))
if !strings.Contains(html, "display-vertical") {
t.Errorf("vertical output missing display-vertical container: %q", html[:min(300, len(html))])
}
@@ -70,7 +70,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"))
+ html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "interlinear", "pl"))
if !strings.Contains(html, "ilverse") {
t.Errorf("interlinear output missing ilverse: %q", html[:min(300, len(html))])
}
@@ -91,7 +91,7 @@ func TestRenderReadingsInterlinear(t *testing.T) {
func TestRenderReadingsInterlinearExcludesPL(t *testing.T) {
secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}}
- html := string(RenderReadings(secs, []string{"pl", "vul"}, "new", "interlinear"))
+ html := string(RenderReadings(secs, []string{"pl", "vul"}, "new", "interlinear", "pl"))
if strings.Contains(html, "Polski (niedziela.pl)") {
t.Errorf("interlinear output should substitute wuj for pl, not carry pl's label: %q", html[:min(300, len(html))])
}
@@ -102,12 +102,30 @@ func TestRenderReadingsInterlinearExcludesPL(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"))
+ html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "pl"))
if strings.Contains(html, "ilverse") {
t.Errorf("expected no ilverse blocks when nothing resolves: %q", html)
}
}
+// TestRenderReadingsLocalizesEN checks that lang="en" localises both the
+// version-column label (via render.GatherVersion) and the section heading's
+// part-label word (via render.LocalizeHeading, brief §3b), while the
+// 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"))
+ if !strings.Contains(html, "Gospel (J 20, 1. 11-18)") {
+ t.Errorf("en output missing localised heading: %q", html[:min(300, len(html))])
+ }
+ if !strings.Contains(html, "Vulgate (Latin)") {
+ t.Errorf("en output missing localised version label: %q", html[:min(300, len(html))])
+ }
+ if strings.Contains(html, "Ewangelia") {
+ t.Errorf("en output should not carry the Polish heading label: %q", html[:min(300, len(html))])
+ }
+}
+
func TestUserTheme(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
diff --git a/internal/web/server.go b/internal/web/server.go
index 10401ed..3b7102c 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -19,6 +19,7 @@ import (
"time"
"github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
"github.com/lukaszkasprzak/lectio/internal/readings"
"github.com/lukaszkasprzak/lectio/internal/render"
@@ -183,6 +184,10 @@ type indexData struct {
Display string
Mono bool
Reading template.HTML
+ // L holds the localised control labels (lectionary/layout/theme/...),
+ // set from i18n.Get(cfg.UILanguage) -- index.html references its
+ // fields (e.g. {{.L.Lectionary}}) instead of hardcoded Polish text.
+ L i18n.UI
}
type versionOpt struct {
@@ -207,7 +212,7 @@ func indexHandler(cfg config.Config) http.HandlerFunc {
}
secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
- reading := renderOrError(secs, loadVersions, lectionary, display, err)
+ reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, err)
// Check the boxes for the versions actually rendered (loadVersions),
// not the raw request: traditional/offline substitute pl->wuj, so the
@@ -239,6 +244,7 @@ func indexHandler(cfg config.Config) http.HandlerFunc {
Display: display,
Mono: queryBool(r, "mono", cfg.WebMono),
Reading: reading,
+ L: i18n.Get(cfg.UILanguage),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -255,7 +261,7 @@ func readingsHandler(cfg config.Config) http.HandlerFunc {
date, lectionary, all, versions, display := resolveQuery(cfg, r)
secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions)
- reading := renderOrError(secs, loadVersions, lectionary, display, err)
+ reading := renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, string(reading))
@@ -266,14 +272,14 @@ func readingsHandler(cfg config.Config) http.HandlerFunc {
// error) a small escaped error paragraph -- readings.Load errors are
// expected in normal operation (an unpublished date, no network while
// online, ...) so the pane should show them, not 500.
-func renderOrError(secs []liturgy.Section, versions []string, lectionary, display string, err error) template.HTML {
+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 RenderReadings(secs, versions, lectionary, display)
+ return RenderReadings(secs, versions, lectionary, display, lang)
}
// 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 47c38e9..25d21f4 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -38,7 +38,10 @@ func TestServer(t *testing.T) {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
- if !strings.Contains(body, "Ewangelia") {
+ // config.Default() -> UILanguage "en", so the gospel heading's part
+ // label is localised to "Gospel" (render.LocalizeHeading); the
+ // citation stays exactly as scraped.
+ if !strings.Contains(body, "Gospel") {
t.Errorf("body missing reading heading: %q", body)
}
if !strings.Contains(body, "htmx") {
@@ -59,7 +62,8 @@ func TestServer(t *testing.T) {
if strings.Contains(body, "<html") {
t.Errorf("partial is not a fragment: %q", body)
}
- if !strings.Contains(body, "Ewangelia") {
+ // See "index page" above: config.Default() is English chrome.
+ if !strings.Contains(body, "Gospel") {
t.Errorf("partial missing reading heading: %q", body)
}
})
@@ -118,7 +122,8 @@ func TestServer(t *testing.T) {
if j := strings.Index(body[i+1:], `class="vnum"`); j != -1 {
block = body[i : i+1+j]
}
- if !strings.Contains(block, "Wujek") || !strings.Contains(block, "Wulgata") {
+ // config.Default() is English chrome: "Wujek (Polish)"/"Vulgate (Latin)".
+ if !strings.Contains(block, "Wujek") || !strings.Contains(block, "Vulgate") {
t.Errorf("interlinear verse block missing both version labels grouped together: %q", block)
}
})
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index a1247e6..8c9fd86 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -32,10 +32,10 @@
hx-vals='{"date":"{{.NextDate}}"}'>&rarr;</button>
</span>
- <label>lekcjonarz
+ <label>{{.L.Lectionary}}
<select name="lectionary">
- <option value="new" {{if eq .Lectionary "new"}}selected{{end}}>nowy</option>
- <option value="traditional" {{if eq .Lectionary "traditional"}}selected{{end}}>tradycyjny</option>
+ <option value="new" {{if eq .Lectionary "new"}}selected{{end}}>{{.L.OptModern}}</option>
+ <option value="traditional" {{if eq .Lectionary "traditional"}}selected{{end}}>{{.L.OptTraditional}}</option>
</select>
</label>
@@ -43,23 +43,23 @@
<label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>
{{end}}
- <label>zakres
+ <label>{{.L.Parts}}
<select name="all">
- <option value="0" {{if not .All}}selected{{end}}>Ewangelia</option>
- <option value="1" {{if .All}}selected{{end}}>wszystkie części</option>
+ <option value="0" {{if not .All}}selected{{end}}>{{.L.OptGospel}}</option>
+ <option value="1" {{if .All}}selected{{end}}>{{.L.OptAll}}</option>
</select>
</label>
- <label>układ
+ <label>{{.L.Layout}}
<select name="display">
- <option value="horizontal" {{if eq .Display "horizontal"}}selected{{end}}>poziomo</option>
- <option value="vertical" {{if eq .Display "vertical"}}selected{{end}}>kolumny</option>
- <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>interlinearnie</option>
+ <option value="horizontal" {{if eq .Display "horizontal"}}selected{{end}}>{{.L.OptHorizontal}}</option>
+ <option value="vertical" {{if eq .Display "vertical"}}selected{{end}}>{{.L.OptColumns}}</option>
+ <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option>
</select>
</label>
</form>
- <label class="theme-picker">motyw
+ <label class="theme-picker">{{.L.Theme}}
<select id="theme-select"
onchange="var o=document.getElementById('theme'),n=o.cloneNode(false);n.setAttribute('href','/theme.css?name='+encodeURIComponent(this.value));o.replaceWith(n);">
{{range .ThemeOpts}}
@@ -69,7 +69,7 @@
</label>
<label class="mono-toggle"><input type="checkbox" {{if .Mono}}checked{{end}}
- onchange="document.body.classList.toggle('mono', this.checked)"> mono</label>
+ onchange="document.body.classList.toggle('mono', this.checked)"> {{.L.Mono}}</label>
<div id="pane">{{.Reading}}</div>