// 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/i18n" "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 ("bt") 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); "bt" cannot participate (no verse // numbers) and is substituted/dropped via render.OfflineVersions' // bt->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. 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": body = renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary, lang)) case "interlinear": body = renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary, lang)) default: body = renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary, lang)) } return renderDayInfo(dayInfo) + body } // renderDayInfo builds the "
`) return template.HTML(b.String()) } // 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 "bt" 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.HeadingWithRef(sec, lang), Subtitle: sec.Subtitle, PartID: sec.PartID, Columns: cols, }) } return views } // interlinearVersions maps versions through render.OfflineVersions, which // substitutes a legacy "bt" with "wuj" (unless "wuj" is already selected). // Every remaining version is a versified corpus that interleaves cleanly. func interlinearVersions(versions []string) []string { return render.OfflineVersions(versions) } // verseSet is one version's verses plus its column label, the unit both the // interlinear readings view and the reader passage view interleave over. type verseSet struct { label string verses []bible.Verse } // interleaveVerses interleaves several versions' verses by chapter:verse: the // ordered union of keys follows the first set's verse order, then any key only // a later set has is appended in that set's order (stable, no duplicates). func interleaveVerses(sets []verseSet) []ilVerseView { 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}) } return vviews } // buildInterlinearViews gathers each requested version's verses via // render.GatherVerses (after the bt->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) views := make([]ilSectionView, 0, len(secs)) for _, sec := range secs { view := ilSectionView{Heading: render.HeadingWithRef(sec, 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 = i18n.Get(lang).NoInterlinearVerses views = append(views, view) continue } view.Verses = interleaveVerses(sets) views = append(views, view) } return views } // webVersionLabel is the localized column label for a version, falling back to // the bare code (mirrors render's unexported versionLabel, which web can't reach). func webVersionLabel(v, lang string) string { if l, ok := i18n.Get(lang).Version[v]; ok { return l } return v } // readerCorpusVersions are the versions the /reader offers: the four embedded // full-text corpora. var readerCorpusVersions = []string{"wuj", "vul", "grb", "drb"} // UnionChapters returns the sorted union of chapter numbers a book has across // all corpus versions, so the reader's chapter navigation is stable regardless // of which versions are currently selected (a version lacking the book just // shows a note). func UnionChapters(canonical string) []int { seen := map[int]bool{} for _, v := range readerCorpusVersions { for _, c := range bible.Chapters(v, canonical) { seen[c] = true } } chaps := make([]int, 0, len(seen)) for c := range seen { chaps = append(chaps, c) } sort.Ints(chaps) return chaps } // RenderPassage builds the reader pane for one book+chapter across versions, in // the same three layouts as RenderReadings (columns / vertical / interlinear), // reusing the very same templates and role classes. name is the book's display // name in the sigla dialect (the section heading is "