diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:20:18 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:20:18 +0200 |
| commit | b72b81c410c161797eb525a6231b0517b4def993 (patch) | |
| tree | d8b2be9acd30b73be2fb1d3600c7004770da15b3 /internal/web/render.go | |
| parent | e4ac5326a42e45281ac8cdcf45757330bae007e9 (diff) | |
| download | lectio-b72b81c410c161797eb525a6231b0517b4def993.tar.gz lectio-b72b81c410c161797eb525a6231b0517b4def993.zip | |
web: HTML render + embedded themes
Diffstat (limited to 'internal/web/render.go')
| -rw-r--r-- | internal/web/render.go | 183 |
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 +} |
