// Package web renders lectio's daily readings as an HTML fragment and // serves a set of "colour only" theme stylesheets (see docs/THEMES.md) that // restyle a fixed set of role classes/variables; it never lays out or types // anything itself -- that lives in the embedded base.css. It consumes // liturgy.Section and render.GatherVersion, never the reverse. package web import ( "bytes" "embed" "fmt" "html/template" "io/fs" "os" "path" "path/filepath" "regexp" "sort" "strings" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/render" ) //go:embed templates var templatesFS embed.FS //go:embed static var staticFS embed.FS // tmpl holds every parsed templates/*.html, keyed by base file name (so // "readings.html" is looked up as "readings.html"). var tmpl = template.Must(template.ParseFS(templatesFS, "templates/*.html")) // verseNumRe matches a bible verse block's "chapter:verse " prefix, as // produced by render.GatherVersion (e.g. "3:16 Tak bowiem..."). Mirrors // internal/tui's verseNumRe. var verseNumRe = regexp.MustCompile(`^(\d+:\d+) (.*)$`) // sectionView, columnView and blockView are the data readings.html ranges // over: one sectionView per liturgy.Section, one columnView per requested // version, one blockView per render.GatherVersion block. type sectionView struct { Heading, Subtitle, PartID string Columns []columnView } type columnView struct { Label string Blocks []blockView } // blockView is one paragraph or verse line. VNum is set (and Text holds // only the verse text) when the block was a bible verse line; otherwise // VNum is empty and Text holds the whole block, with Refrain set for a // responsorial psalm's deduped first ("pl") block. type blockView struct { VNum, Text string Refrain bool } // ilSectionView, ilVerseView and ilLineView are what // templates/readings-interlinear.html ranges over: one ilSectionView per // liturgy.Section, one ilVerseView per chapter:verse key (in ordered-union // order, see buildInterlinearViews), one ilLineView per version that carries // that verse. Note holds a short escaped message in place of Verses when no // requested version could be interleaved for the section. type ilSectionView struct { Heading, Subtitle, PartID string Verses []ilVerseView Note string } type ilVerseView struct { VNum string Lines []ilLineView } type ilLineView struct { Label, Text string } // RenderReadings builds the reading pane fragment for one of three layouts: // // - "horizontal" (or anything unrecognized): the original stacked layout, // one column per version rendered under a shared heading, unchanged. // - "vertical": the same per-version columns side by side in a // ".display-vertical" grid, like the CLI `compare` view. // - "interlinear": versions interleaved verse-by-verse by chapter:verse // (see buildInterlinearViews); "pl" cannot participate (no verse // numbers) and is substituted/dropped via render.OfflineVersions' // pl->wuj transform before gathering. // // In every mode, heading, citation (subtitle), verse-number and refrain // text are wrapped in class="heading|citation|vnum|refrain|version-label" // spans so theme CSS can restyle them; verse/paragraph text is escaped by // html/template. 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, lang)) case "interlinear": return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary, lang)) default: return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary, lang)) } } // renderTemplate executes the named embedded template with data, degrading // to a visible, escaped error paragraph on failure (should be unreachable: // the templates are embedded and fixed at build time) rather than panicking // a request handler in the caller. func renderTemplate(name string, data any) template.HTML { var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { return template.HTML("
" + template.HTMLEscapeString(err.Error()) + "
") } return template.HTML(buf.String()) } // buildColumnViews gathers each section's per-version columns via // render.GatherVersion -- the shared data both the "horizontal" // (readings.html) and "vertical" (readings-vertical.html) templates range // over; only the surrounding markup differs between the two layouts. 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, lang) bviews := make([]blockView, 0, len(blocks)) for bi, b := range blocks { // A bible verse line always carries its "chapter:verse " // prefix; only text without one (i.e. the "pl" version's // paragraphs) can be the deduped psalm refrain -- matches // internal/tui's styleBlock precedence. if m := verseNumRe.FindStringSubmatch(b); m != nil { bviews = append(bviews, blockView{VNum: m[1], Text: m[2]}) continue } bviews = append(bviews, blockView{Text: b, Refrain: isPsalm && bi == 0}) } cols = append(cols, columnView{Label: label, Blocks: bviews}) } views = append(views, sectionView{ Heading: render.LocalizeHeading(sec.Heading, sec.PartID, lang), Subtitle: sec.Subtitle, PartID: sec.PartID, Columns: cols, }) } return views } // interlinearVersions maps versions through the same pl->wuj substitution // render.OfflineVersions performs for offline mode: "pl" (niedziela.pl // paragraph text) carries no verse numbers and cannot interleave, so it is // dropped, substituting "wuj" (the Polish-language bible version) in its // place unless "wuj" was already selected. Reuses render.OfflineVersions // rather than duplicating its two-line transform. func interlinearVersions(versions []string) []string { return render.OfflineVersions(versions) } // buildInterlinearViews gathers each requested version's verses via // render.GatherVerses (after the pl->wuj substitution, see // interlinearVersions) and interleaves them by chapter:verse: the ordered // union of keys is taken from the first versified version's own verse // order, then any keys that only appear in a later version are appended in // that version's order (stable, no duplicates). A version that comes back // unversified (a bible lookup miss) is simply skipped -- the remaining // versions still render. A section where nothing could be interleaved gets // a short escaped Note instead of an empty Verses list. 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 { label string verses []bible.Verse } views := make([]ilSectionView, 0, len(secs)) for _, sec := range secs { 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, lang) if !versified { continue } sets = append(sets, verseSet{label: label, verses: verses}) } if len(sets) == 0 { view.Note = "(brak wersetów do zestawienia interlinearnego)" views = append(views, view) continue } type vkey struct{ chapter, verse int } order := make([]vkey, 0) seen := map[vkey]bool{} bySet := make([]map[vkey]bible.Verse, len(sets)) for i, s := range sets { m := make(map[vkey]bible.Verse, len(s.verses)) for _, v := range s.verses { k := vkey{v.Chapter, v.Verse} m[k] = v if !seen[k] { seen[k] = true order = append(order, k) } } bySet[i] = m } vviews := make([]ilVerseView, 0, len(order)) for _, k := range order { var lines []ilLineView for i, s := range sets { if v, ok := bySet[i][k]; ok { lines = append(lines, ilLineView{Label: s.label, Text: v.Text}) } } vviews = append(vviews, ilVerseView{ VNum: fmt.Sprintf("%d:%d", k.chapter, k.verse), Lines: lines, }) } view.Verses = vviews views = append(views, view) } return views } // Themes returns the sorted, deduplicated union of the embedded theme // stems (static/themes/*.css) and the *.css stems found in the user theme // directory (${XDG_CONFIG_HOME:-~/.config}/lectio/themes/); a user theme // overrides a built-in of the same name but does not add a second entry. func Themes() []string { set := map[string]bool{} for _, name := range themeStems(staticFS, "static/themes") { set[name] = true } if dir, err := userThemesDir(); err == nil { for _, name := range themeStems(os.DirFS(dir), ".") { set[name] = true } } names := make([]string, 0, len(set)) for name := range set { names = append(names, name) } sort.Strings(names) return names } // themeNameRe is the allowlist a theme name must match: letters, digits, // underscore, hyphen only. Legitimate theme stems (built-in or user) already // fit this shape; it is deliberately stricter than "no path separators" so // it rejects "." / ".." / any other filesystem metacharacter outright // instead of trying to enumerate what's unsafe. var themeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // themeCSS returns one theme's CSS: the user file // ${XDG_CONFIG_HOME:-~/.config}/lectio/themes/