Refactor html templates
This commit entirely refactors the use of html templates. Instead of inheriting from a shared template, we insert the results into a base template.
This commit is contained in:
parent
6ede99d7c6
commit
d235ee4e5c
59 changed files with 869 additions and 777 deletions
22
internal/dis/component/server/templating/assets.go
Normal file
22
internal/dis/component/server/templating/assets.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
)
|
||||
|
||||
// CustomAssetsPath is the path custom assets are stored at
|
||||
func (tpl *Templating) CustomAssetsPath() string {
|
||||
return filepath.Join(tpl.Config.DeployRoot, "core", "assets")
|
||||
}
|
||||
|
||||
func (tpl *Templating) CustomAssetPath(name string) string {
|
||||
return filepath.Join(tpl.CustomAssetsPath(), name)
|
||||
}
|
||||
|
||||
func (tpl *Templating) BackupName() string { return "custom" }
|
||||
|
||||
func (tpl *Templating) Backup(context component.StagingContext) error {
|
||||
return context.CopyDirectory("", tpl.CustomAssetsPath())
|
||||
}
|
||||
193
internal/dis/component/server/templating/base.go
Normal file
193
internal/dis/component/server/templating/base.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
//go:embed "src/base.html"
|
||||
var baseHTML string
|
||||
var baseTemplate = template.Must(template.New("base.html").Parse(baseHTML))
|
||||
|
||||
// Tempalte represents an executable template.
|
||||
type Template[C any] struct {
|
||||
templating *Templating
|
||||
p *Parsed[C]
|
||||
}
|
||||
|
||||
// Template returns a template that, if executed together with the context by the Context method, produces the desired result.
|
||||
func (tpl *Template[C]) Template() *template.Template {
|
||||
return baseTemplate
|
||||
}
|
||||
|
||||
// Context generates the context to pass to an instance of the template returned by Template.
|
||||
func (tpl *Template[C]) Context(r *http.Request, c C, funcs ...FlagFunc) (ctx *tContext[C]) {
|
||||
// create a new context
|
||||
ctx = new(tContext[C])
|
||||
|
||||
// setup the basic properties
|
||||
ctx.ctx = r.Context()
|
||||
ctx.Runtime.RequestURI = r.URL.RequestURI()
|
||||
ctx.Runtime.GeneratedAt = time.Now().UTC()
|
||||
ctx.Runtime.CSRF = csrf.TemplateField(r)
|
||||
ctx.Runtime.Menu = tpl.templating.buildMenu(r)
|
||||
|
||||
// generate the rest of the options
|
||||
ctx.Runtime.Flags = ctx.Runtime.Flags.Apply(r, tpl.p.funcs...)
|
||||
ctx.Runtime.Flags = ctx.Runtime.Flags.Apply(r, funcs...)
|
||||
|
||||
// if the context has a runtime flags embed, then set the field properly
|
||||
if tpl.p.hasRuntimeFlagsEmbed {
|
||||
reflect.ValueOf(&c).Elem().
|
||||
FieldByName(runtimeFlagsName).
|
||||
Set(reflect.ValueOf(ctx.Runtime))
|
||||
}
|
||||
|
||||
// the main template
|
||||
ctx.cMain = c
|
||||
ctx.tMain = tpl.p.tpl
|
||||
|
||||
// the footer template
|
||||
ctx.tFooter = tpl.templating.GetCustomizable(footerTemplate)
|
||||
ctx.cFooter = ctx.Runtime
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ParseForm is like Parse[BaseFormContext]
|
||||
var ParseForm = Parse[FormContext]
|
||||
|
||||
type FormContext struct {
|
||||
httpx.FormContext
|
||||
RuntimeFlags
|
||||
}
|
||||
|
||||
// NewFormContext returns a new FormContext from an underlying context
|
||||
func NewFormContext(context httpx.FormContext) FormContext {
|
||||
return FormContext{FormContext: context}
|
||||
}
|
||||
|
||||
// FormTemplateContext returns a new handler for a form with the given base context
|
||||
func FormTemplateContext(tw *Template[FormContext]) func(ctx httpx.FormContext, r *http.Request) any {
|
||||
// TODO: Is this needed?
|
||||
return func(ctx httpx.FormContext, r *http.Request) any {
|
||||
return tw.Context(r, FormContext{FormContext: ctx})
|
||||
}
|
||||
}
|
||||
|
||||
// Hander returns a function that returns a context for the given template
|
||||
func (tw *Template[C]) Handler(f func(r *http.Request) (C, error)) func(r *http.Request) (any, error) {
|
||||
// TODO: Should this one be removed?
|
||||
return tw.HandlerWithFlags(func(r *http.Request) (C, []FlagFunc, error) {
|
||||
c, err := f(r)
|
||||
return c, nil, err
|
||||
})
|
||||
}
|
||||
|
||||
// HTMLHandler returns a new HTMLHandler for this request
|
||||
func (tw *Template[C]) HTMLHandler(f func(r *http.Request) (C, error)) httpx.HTMLHandler[any] {
|
||||
return httpx.HTMLHandler[any]{
|
||||
Handler: tw.Handler(f),
|
||||
Template: tw.Template(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandlerWithFlags works like handler, but additionally receive funcs to generate flags
|
||||
func (tw *Template[C]) HandlerWithFlags(f func(r *http.Request) (C, []FlagFunc, error)) func(r *http.Request) (any, error) {
|
||||
return func(r *http.Request) (any, error) {
|
||||
c, funcs, err := f(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tw.Context(r, c, funcs...), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *Template[C]) HTMLHandlerWithFlags(f func(r *http.Request) (C, []FlagFunc, error)) httpx.HTMLHandler[any] {
|
||||
return httpx.HTMLHandler[any]{
|
||||
Handler: tw.HandlerWithFlags(f),
|
||||
Template: tw.Template(),
|
||||
}
|
||||
}
|
||||
|
||||
// tContext is passed to the underlying template.
|
||||
//
|
||||
// Callers may not retain references beyond the invocation of the template.
|
||||
// Callers must not rely on the internal structure of this tContext.
|
||||
type tContext[C any] struct {
|
||||
Runtime RuntimeFlags // underlying flags
|
||||
|
||||
ctx context.Context // underlying context for render
|
||||
|
||||
// the main template and context
|
||||
tMain *template.Template
|
||||
cMain C
|
||||
|
||||
// the footer template and context
|
||||
tFooter *template.Template
|
||||
cFooter RuntimeFlags
|
||||
}
|
||||
|
||||
// Main renders the main template.
|
||||
func (ctx *tContext[C]) Main() (template.HTML, error) {
|
||||
return ctx.renderSafe("main", ctx.tMain, ctx.cMain)
|
||||
}
|
||||
|
||||
// Footer renders the footer template
|
||||
func (ctx *tContext[C]) Footer() (template.HTML, error) {
|
||||
return ctx.renderSafe("footer", ctx.tFooter, ctx.cFooter)
|
||||
}
|
||||
|
||||
const renderSafeError = "Error displaying page. See server log for details. "
|
||||
|
||||
func (ctx *tContext[C]) renderSafe(name string, t *template.Template, c any) (template.HTML, error) {
|
||||
|
||||
// already done
|
||||
if err := ctx.ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
value, err, panicked := func() (value template.HTML, err error, panicked bool) {
|
||||
var builder strings.Builder
|
||||
|
||||
defer func() {
|
||||
if panicked {
|
||||
r := recover()
|
||||
zerolog.Ctx(ctx.ctx).Error().
|
||||
Str("uri", ctx.Runtime.RequestURI).
|
||||
Str("name", name).
|
||||
Str("panic", fmt.Sprint(r)).
|
||||
Msg("templating.Main(): template panic()ed")
|
||||
}
|
||||
}()
|
||||
|
||||
panicked = true
|
||||
err = t.Execute(&builder, c)
|
||||
panicked = false
|
||||
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx.ctx).Err(err).
|
||||
Str("uri", ctx.Runtime.RequestURI).
|
||||
Str("name", name).
|
||||
Msg("template errored")
|
||||
}
|
||||
|
||||
return template.HTML(builder.String()), err, false
|
||||
}()
|
||||
|
||||
if err != nil || panicked {
|
||||
return renderSafeError, httpx.ErrInternalServerError
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
101
internal/dis/component/server/templating/flags.go
Normal file
101
internal/dis/component/server/templating/flags.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/tkw1536/goprogram/lib/reflectx"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// Flags represent handle-updatable options for the base template
|
||||
type Flags struct {
|
||||
Title string // Title of the menu
|
||||
assets.Assets // assets are the assets included in the template
|
||||
|
||||
Crumbs []component.MenuItem // crumbs are the breadcrumbs leading to a specific action
|
||||
Actions []component.MenuItem // actions are the actions available to a specific thingy
|
||||
}
|
||||
|
||||
// Apply applies a set of functions to this flags
|
||||
func (flags Flags) Apply(r *http.Request, funcs ...FlagFunc) Flags {
|
||||
for _, f := range funcs {
|
||||
flags = f(flags, r)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// RuntimeFlags are passed to the template at runtime.
|
||||
// Any context may e
|
||||
type RuntimeFlags struct {
|
||||
Flags
|
||||
|
||||
RequestURI string // request uri of the current page
|
||||
Menu []component.MenuItem // menu at the top of the page
|
||||
GeneratedAt time.Time // time the underlying data returned
|
||||
CSRF template.HTML // csrf data (if any)
|
||||
}
|
||||
|
||||
var runtimeFlagsName = reflectx.TypeOf[RuntimeFlags]().Name()
|
||||
|
||||
// Clone clones this flags
|
||||
func (flags Flags) Clone() Flags {
|
||||
flags.Crumbs = slices.Clone(flags.Crumbs)
|
||||
flags.Actions = slices.Clone(flags.Actions)
|
||||
return flags
|
||||
}
|
||||
|
||||
// FlagFunc updates a flags based on a request.
|
||||
// FlagFunc may not be nil.
|
||||
type FlagFunc func(flags Flags, r *http.Request) Flags
|
||||
|
||||
// Assets sets the given assets for the given flags
|
||||
func Assets(Assets assets.Assets) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Assets = Assets
|
||||
return flags
|
||||
}
|
||||
}
|
||||
|
||||
// Crumbs sets the crumbs
|
||||
func Crumbs(crumbs ...component.MenuItem) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Crumbs = crumbs
|
||||
return flags
|
||||
}
|
||||
}
|
||||
|
||||
// Actions sets the actions
|
||||
func Actions(actions ...component.MenuItem) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Actions = actions
|
||||
return flags
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceAction replaces a specific action
|
||||
func ReplaceAction(index int, action component.MenuItem) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Actions[index] = action
|
||||
return flags
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceCrumb replaces a specific crum
|
||||
func ReplaceCrumb(index int, action component.MenuItem) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Crumbs[index] = action
|
||||
return flags
|
||||
}
|
||||
}
|
||||
|
||||
// Title sets the title of this template
|
||||
func Title(title string) FlagFunc {
|
||||
return func(flags Flags, r *http.Request) Flags {
|
||||
flags.Title = title
|
||||
return flags
|
||||
}
|
||||
}
|
||||
49
internal/dis/component/server/templating/menu.go
Normal file
49
internal/dis/component/server/templating/menu.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/mux"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// buildMenu builds the manu for this request for all known components in this distillery.
|
||||
//
|
||||
// NOTE(twiesing): Don't name this method "Menu", as it will cause a stack overflow.
|
||||
func (tpl *Templating) buildMenu(r *http.Request) []component.MenuItem {
|
||||
|
||||
path := mux.NormalizePath(r.URL.Path)
|
||||
|
||||
// get the static menu items, and then return all the regular ones
|
||||
var items []component.MenuItem
|
||||
for _, m := range tpl.Dependencies.Menuable {
|
||||
items = append(items, m.Menu(r)...)
|
||||
}
|
||||
for i, item := range items {
|
||||
items[i].Active = string(item.Path) == path
|
||||
}
|
||||
slices.SortFunc(items, component.MenuItemSort)
|
||||
return items
|
||||
}
|
||||
|
||||
// Menu returns a list of menu items provided by routeables
|
||||
func (tpl *Templating) Menu(r *http.Request) []component.MenuItem {
|
||||
return tpl.menu.Get(func() []component.MenuItem {
|
||||
items := make([]component.MenuItem, 0, len(tpl.Dependencies.Routeables))
|
||||
for _, route := range tpl.Dependencies.Routeables {
|
||||
routes := route.Routes()
|
||||
if routes.MenuTitle == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, component.MenuItem{
|
||||
Title: routes.MenuTitle,
|
||||
Priority: routes.MenuPriority,
|
||||
Path: template.URL(routes.Prefix),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(items, component.MenuItemSort)
|
||||
return items
|
||||
})
|
||||
}
|
||||
66
internal/dis/component/server/templating/parse.go
Normal file
66
internal/dis/component/server/templating/parse.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"reflect"
|
||||
|
||||
"github.com/tkw1536/goprogram/lib/reflectx"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// Parsed represents a parsed template that takes as argument a context of type C.
|
||||
type Parsed[C any] struct {
|
||||
// does the context type an embed of the runtime flags type?
|
||||
hasRuntimeFlagsEmbed bool
|
||||
|
||||
tpl *template.Template // parsed template
|
||||
funcs []FlagFunc // optionally concfigured functions.
|
||||
}
|
||||
|
||||
// Parse parses a template with the given name and source.
|
||||
// If base is not nil, every template associated with the base template is copied into the given template.
|
||||
// Functions will be applied on creation time to represent the context for the given template.
|
||||
func Parse[C any](name string, source []byte, base *template.Template, funcs ...FlagFunc) Parsed[C] {
|
||||
tp := reflectx.TypeOf[C]()
|
||||
|
||||
// determine if we have an embedded field in the struct
|
||||
var hasEmbed bool
|
||||
if tp.Kind() == reflect.Struct {
|
||||
field, ok := tp.FieldByName(runtimeFlagsName)
|
||||
if ok {
|
||||
hasEmbed = field.Anonymous
|
||||
}
|
||||
}
|
||||
|
||||
// create a new template, and optionally inherit from the base template
|
||||
new := template.New(name)
|
||||
if base != nil {
|
||||
for _, tree := range base.Templates() {
|
||||
root := tree.Tree.Copy()
|
||||
new.AddParseTree(tree.Name(), root)
|
||||
}
|
||||
}
|
||||
|
||||
return Parsed[C]{
|
||||
hasRuntimeFlagsEmbed: hasEmbed,
|
||||
tpl: template.Must(new.Parse(string(source))),
|
||||
funcs: funcs,
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare prepares this template to be used with the given templating.
|
||||
func (p *Parsed[C]) Prepare(templating *Templating, funcs ...FlagFunc) *Template[C] {
|
||||
pcopy := *p // make a copy of p!
|
||||
|
||||
wrap := Template[C]{
|
||||
templating: templating,
|
||||
|
||||
p: &pcopy,
|
||||
}
|
||||
|
||||
// copy the functions!
|
||||
pcopy.funcs = slices.Clone(pcopy.funcs)
|
||||
pcopy.funcs = append(wrap.p.funcs, funcs...)
|
||||
|
||||
return &wrap
|
||||
}
|
||||
51
internal/dis/component/server/templating/src/base.html
Normal file
51
internal/dis/component/server/templating/src/base.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>{{ .Runtime.Flags.Title }}</title>
|
||||
{{ .Runtime.Flags.Assets.Styles }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="pure-menu pure-menu-horizontal">
|
||||
<ul class="pure-menu-list" role="menubar">
|
||||
{{ range .Runtime.Menu }}
|
||||
<li class="pure-menu-item{{ if .Active }} pure-menu-selected{{ end }}">
|
||||
<a href="{{ .Path }}" class="pure-menu-link">{{ .Title }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav class="breadcrumbs" role="navigation" aria-label="Breadcrumbs">
|
||||
{{ range .Runtime.Flags.Crumbs }}
|
||||
<a class="{{ if .Active }}active{{ end }}" href="{{ .Path }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1 id="top">{{ .Runtime.Flags.Title }}</h1>
|
||||
{{ if .Runtime.Flags.Actions }}
|
||||
<div class="pure-button-group" role="group" aria-label="Actions">
|
||||
{{ range .Runtime.Flags.Actions }}
|
||||
<a href="{{ .Path }}" class="pure-button{{ if eq .Priority -1 }} pure-button-small{{end}}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
<main>
|
||||
<div class="pure-g">
|
||||
{{ .Main }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
{{ .Footer }}
|
||||
</footer>
|
||||
|
||||
|
||||
{{ .Runtime.Flags.Assets.Scripts }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
3
internal/dis/component/server/templating/src/footer.html
Normal file
3
internal/dis/component/server/templating/src/footer.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<p>
|
||||
Generated <time format="{{ .GeneratedAt.Format "2006-01-02T15:04:05Z" }}">{{ .GeneratedAt.Format "2006-01-02T15:04:05Z07:00" }}</time> by <a href="https://github.com/FAU-CDI/wisski-distillery" target="_blank" rel="noopener noreferer">WissKI Distillery</a>. This site might use cookies for essential purposes, see also <a href="/legal/">Legal Notices</a>.
|
||||
</p>
|
||||
29
internal/dis/component/server/templating/template.go
Normal file
29
internal/dis/component/server/templating/template.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
|
||||
)
|
||||
|
||||
//go:embed "src/footer.html"
|
||||
var footerHTML string
|
||||
var footerTemplate = template.Must(template.New("footer.html").Parse(footerHTML))
|
||||
|
||||
// GetCustomizable returns either a clone of dflt, or the overriden template with the same name.
|
||||
func (tpl *Templating) GetCustomizable(dflt *template.Template) *template.Template {
|
||||
name := dflt.Name()
|
||||
|
||||
custom, err := (func() (*template.Template, error) {
|
||||
data, err := environment.ReadFile(tpl.Environment, tpl.CustomAssetPath(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return template.New(name).Parse(string(data))
|
||||
})()
|
||||
if err != nil {
|
||||
return template.Must(dflt.Clone())
|
||||
}
|
||||
return custom
|
||||
}
|
||||
21
internal/dis/component/server/templating/templating.go
Normal file
21
internal/dis/component/server/templating/templating.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package templating
|
||||
|
||||
import (
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
|
||||
)
|
||||
|
||||
// Templating implements templating customization
|
||||
type Templating struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Routeables []component.Routeable
|
||||
Menuable []component.Menuable
|
||||
}
|
||||
menu lazy.Lazy[[]component.MenuItem]
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Backupable = (*Templating)(nil)
|
||||
_ component.Menuable = (*Templating)(nil)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue