forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview/pages: rework caching mechanism

instead of loading all templates at once and storing into a map, we now
memoize the results of `parse`. the first call to `parse` will require
calculation but subsequent calls will be cached.

this is simpler to reason about because the new execution model requires
us to parse differently for each "base" template that is being used:

- for timeline, it is necessary to parse with layouts/base
- for repo-index, it is necessary to parse with layouts/base and
layouts/repobase in that order

the previous approach to loading also had a latent bug: all layouts were
loaded atop each other in alphabetical order (order of iteration over
the filesystem), and therefore it was not possible to selectively parse
and execute templates on a subset of layouts.

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li e11ecc53 21a3b2a6

verified
Changed files
+132 -84
appview
+35
appview/pages/cache.go
···
+
package pages
+
+
import (
+
"sync"
+
)
+
+
type TmplCache[K comparable, V any] struct {
+
data map[K]V
+
mutex sync.RWMutex
+
}
+
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
+
return &TmplCache[K, V]{
+
data: make(map[K]V),
+
}
+
}
+
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
val, exists := c.data[key]
+
return val, exists
+
}
+
+
func (c *TmplCache[K, V]) Set(key K, value V) {
+
c.mutex.Lock()
+
defer c.mutex.Unlock()
+
c.data[key] = value
+
}
+
+
func (c *TmplCache[K, V]) Size() int {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
return len(c.data)
+
}
+97 -84
appview/pages/pages.go
···
var Files embed.FS
type Pages struct {
-
mu sync.RWMutex
-
t map[string]*template.Template
+
mu sync.RWMutex
+
cache *TmplCache[string, *template.Template]
avatar config.AvatarConfig
resolver *idresolver.Resolver
···
p := &Pages{
mu: sync.RWMutex{},
-
t: make(map[string]*template.Template),
+
cache: NewTmplCache[string, *template.Template](),
dev: config.Core.Dev,
avatar: config.Avatar,
rctx: rctx,
···
logger: slog.Default().With("component", "pages"),
}
-
// Initial load of all templates
-
p.loadAllTemplates()
+
if p.dev {
+
p.embedFS = os.DirFS(p.templateDir)
+
} else {
+
p.embedFS = Files
+
}
return p
+
}
+
+
func (p *Pages) pathToName(s string) string {
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
+
}
+
+
// reverse of pathToName
+
func (p *Pages) nameToPath(s string) string {
+
return "templates/" + s + ".html"
}
func (p *Pages) fragmentPaths() ([]string, error) {
···
return fragmentPaths, nil
}
-
func (p *Pages) loadAllTemplates() {
-
if p.dev {
-
p.embedFS = os.DirFS(p.templateDir)
-
} else {
-
p.embedFS = Files
-
}
-
-
l := p.logger.With("handler", "loadAllTemplates")
-
templates := make(map[string]*template.Template)
+
func (p *Pages) fragments() (*template.Template, error) {
fragmentPaths, err := p.fragmentPaths()
if err != nil {
-
l.Error("failed to collect fragments", "err", err)
-
return
+
return nil, err
}
+
funcs := p.funcMap()
+
// parse all fragments together
-
allFragments := template.New("").Funcs(p.funcMap())
+
allFragments := template.New("").Funcs(funcs)
for _, f := range fragmentPaths {
-
name := strings.TrimPrefix(f, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
pf, err := template.New(name).Funcs(p.funcMap()).ParseFS(p.embedFS, f)
+
name := p.pathToName(f)
+
+
pf, err := template.New(name).
+
Funcs(funcs).
+
ParseFS(p.embedFS, f)
if err != nil {
-
l.Error("failed to parse fragment", "name", name, "path", f)
-
return
+
return nil, err
}
+
allFragments, err = allFragments.AddParseTree(name, pf.Tree)
if err != nil {
-
l.Error("failed to add parse tree", "name", name, "path", f)
-
return
+
return nil, err
}
-
templates[name] = allFragments.Lookup(name)
+
}
+
+
return allFragments, nil
+
}
+
+
// parse without memoization
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
+
paths, err := p.fragmentPaths()
+
if err != nil {
+
return nil, err
+
}
+
for _, s := range stack {
+
paths = append(paths, p.nameToPath(s))
}
-
// Then walk through and setup the rest of the templates
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
-
if err != nil {
-
return err
-
}
-
if d.IsDir() {
-
return nil
-
}
-
if !strings.HasSuffix(path, "html") {
-
return nil
-
}
-
// Skip fragments as they've already been loaded
-
if strings.Contains(path, "fragments/") {
-
return nil
-
}
-
// Skip layouts
-
if strings.Contains(path, "layouts/") {
-
return nil
-
}
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
// Add the page template on top of the base
-
allPaths := []string{}
-
allPaths = append(allPaths, "templates/layouts/*.html")
-
allPaths = append(allPaths, fragmentPaths...)
-
allPaths = append(allPaths, path)
-
tmpl, err := template.New(name).
-
Funcs(p.funcMap()).
-
ParseFS(p.embedFS, allPaths...)
-
if err != nil {
-
return fmt.Errorf("setting up template: %w", err)
-
}
-
templates[name] = tmpl
-
l.Debug("loaded all templates")
-
return nil
-
})
+
+
funcs := p.funcMap()
+
top := stack[len(stack)-1]
+
parsed, err := template.New(top).
+
Funcs(funcs).
+
ParseFS(p.embedFS, paths...)
if err != nil {
-
l.Error("walking template dir", "err", err)
-
panic(err)
+
return nil, err
}
-
l.Info("loaded all templates", "total", len(templates))
-
p.mu.Lock()
-
defer p.mu.Unlock()
-
p.t = templates
+
return parsed, nil
}
-
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
-
// In dev mode, reparse templates from disk before executing
-
if p.dev {
-
p.loadAllTemplates()
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
+
key := strings.Join(stack, "|")
+
+
// never cache in dev mode
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
+
return cached, nil
}
-
p.mu.RLock()
-
defer p.mu.RUnlock()
-
tmpl, exists := p.t[templateName]
-
if !exists {
-
return fmt.Errorf("template not found: %s", templateName)
+
result, err := p.rawParse(stack...)
+
if err != nil {
+
return nil, err
}
-
if base == "" {
-
return tmpl.Execute(w, params)
-
} else {
-
return tmpl.ExecuteTemplate(w, base, params)
+
p.cache.Set(key, result)
+
return result, nil
+
}
+
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
top,
}
+
return p.parse(stack...)
}
-
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/base", params)
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/repobase",
+
top,
+
}
+
return p.parse(stack...)
}
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "", params)
+
tpl, err := p.parse(name)
+
if err != nil {
+
return err
+
}
+
+
return tpl.Execute(w, params)
+
}
+
+
func (p *Pages) execute(name string, w io.Writer, params any) error {
+
tpl, err := p.parseBase(name)
+
if err != nil {
+
return err
+
}
+
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/repobase", params)
+
tpl, err := p.parseRepoBase(name)
+
if err != nil {
+
return err
+
}
+
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
func (p *Pages) Favicon(w io.Writer) error {