1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
// 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
}
// 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/<name>.css if it exists,
// otherwise the embedded static/themes/<name>.css, otherwise an error.
// name must match themeNameRe -- 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 !themeNameRe.MatchString(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
}
|