aboutsummaryrefslogtreecommitdiff
path: root/internal/web/render.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/render.go')
-rw-r--r--internal/web/render.go183
1 files changed, 183 insertions, 0 deletions
diff --git a/internal/web/render.go b/internal/web/render.go
new file mode 100644
index 0000000..cf72d41
--- /dev/null
+++ b/internal/web/render.go
@@ -0,0 +1,183 @@
+// 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/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
+}
+
+// RenderReadings builds the reading pane: for each section, a heading (and
+// subtitle if present) followed by one column per version, each built by
+// calling render.GatherVersion(v, sec, lectionary). Heading, citation
+// (subtitle), verse-number and refrain text are wrapped in
+// class="heading|citation|vnum|refrain" spans so theme CSS can restyle
+// them; verse/paragraph text is escaped by html/template.
+func RenderReadings(secs []liturgy.Section, versions []string, lectionary string) template.HTML {
+ 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)
+
+ 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: sec.Heading,
+ Subtitle: sec.Subtitle,
+ PartID: sec.PartID,
+ Columns: cols,
+ })
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, "readings.html", views); err != nil {
+ // Should be unreachable (the template is embedded and fixed at
+ // build time); degrade to a visible, escaped error rather than
+ // panicking a request handler in the caller.
+ return template.HTML("<p class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</p>")
+ }
+ return template.HTML(buf.String())
+}
+
+// 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
+}
+
+// themeCSS returns one theme's CSS: the user file
+// ${XDG_CONFIG_HOME:-~/.config}/lectio/themes/<name>.css if it exists,
+// otherwise the embedded static/themes/<name>.css, otherwise an error.
+// name must be a bare file stem (no path separators or "..") -- callers
+// (B2's /theme.css?name= handler) pass this straight through from an HTTP
+// query parameter, so this rejects path traversal rather than trusting it.
+func themeCSS(name string) ([]byte, error) {
+ if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
+ return nil, fmt.Errorf("web: invalid theme name %q", name)
+ }
+
+ if dir, err := userThemesDir(); err == nil {
+ if b, err := os.ReadFile(filepath.Join(dir, name+".css")); err == nil {
+ return b, nil
+ }
+ }
+
+ b, err := staticFS.ReadFile(path.Join("static", "themes", name+".css"))
+ if err != nil {
+ return nil, fmt.Errorf("web: unknown theme %q", name)
+ }
+ return b, nil
+}
+
+// userThemesDir resolves ${XDG_CONFIG_HOME:-~/.config}/lectio/themes via
+// os.UserConfigDir(), which already honors XDG_CONFIG_HOME on Linux.
+func userThemesDir() (string, error) {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "lectio", "themes"), nil
+}
+
+// themeStems lists the *.css stems (base name, no extension) directly in
+// dir of fsys, silently returning none if dir doesn't exist or isn't
+// readable -- a missing user theme directory is normal, not an error.
+func themeStems(fsys fs.FS, dir string) []string {
+ entries, err := fs.ReadDir(fsys, dir)
+ if err != nil {
+ return nil
+ }
+ var names []string
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".css") {
+ continue
+ }
+ names = append(names, strings.TrimSuffix(e.Name(), ".css"))
+ }
+ return names
+}