aboutsummaryrefslogtreecommitdiff
path: root/internal/web/render.go
blob: cf72d41a517210a1861bf5eea87d9a7baef9558b (plain) (blame)
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
// 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
}