Refactor server and templates package
This commit is contained in:
parent
b6bf0a8900
commit
6ede99d7c6
105 changed files with 341 additions and 339 deletions
22
internal/dis/component/server/templates/assets.go
Normal file
22
internal/dis/component/server/templates/assets.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package templates
|
||||
|
||||
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())
|
||||
}
|
||||
102
internal/dis/component/server/templates/context.go
Normal file
102
internal/dis/component/server/templates/context.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/tkw1536/goprogram/lib/reflectx"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// baseContextName is the name of the [BaseContext] type
|
||||
var baseContextName = reflectx.TypeOf[BaseContext]().Name()
|
||||
|
||||
// BaseContext represents a context used by templates
|
||||
//
|
||||
// Other invocations might cause an error at runtime.
|
||||
type BaseContext struct {
|
||||
inited bool // has this context been inited?
|
||||
requestWasNil bool // was the passed request nil
|
||||
|
||||
GeneratedAt time.Time // time this page was generated at
|
||||
|
||||
// Menu and breadcrumbs
|
||||
Menu []component.MenuItem
|
||||
BaseContextGaps
|
||||
|
||||
CSRF template.HTML // CSRF Field
|
||||
}
|
||||
|
||||
// constants that are used in various parts of the template to render stuff
|
||||
const (
|
||||
errorPrefix template.HTML = `<div style="z-index:10000;position:fixed;top:0;left:0;width:100vh;height:100vw;background:red;text-align:center;padding:10vh 10vw;font-size:xx-large;font-weight:bold">`
|
||||
errorSuffix template.HTML = "</div>"
|
||||
|
||||
csrfError template.HTML = errorPrefix + "CSRF used but not provided" + errorSuffix
|
||||
initError template.HTML = errorPrefix + "<code>BaseContext.use()</code> not called" + errorSuffix
|
||||
requestNilError template.HTML = errorPrefix + "<code>BaseContext.use()</code> called with nil request" + errorSuffix
|
||||
)
|
||||
|
||||
type BaseContextGaps struct {
|
||||
Crumbs []component.MenuItem
|
||||
Actions []component.MenuItem
|
||||
}
|
||||
|
||||
func (bcg BaseContextGaps) clone() BaseContextGaps {
|
||||
return BaseContextGaps{
|
||||
Crumbs: slices.Clone(bcg.Crumbs),
|
||||
Actions: slices.Clone(bcg.Actions),
|
||||
}
|
||||
}
|
||||
|
||||
// update updates an embedded BaseContext field in context.
|
||||
func (tpl *Templating) update(context any, r *http.Request, bcg BaseContextGaps) *BaseContext {
|
||||
tc := reflect.ValueOf(context).
|
||||
Elem().FieldByName(baseContextName).Addr().
|
||||
Interface().(*BaseContext)
|
||||
|
||||
tc.inited = true
|
||||
tc.requestWasNil = r == nil
|
||||
|
||||
tc.GeneratedAt = time.Now().UTC()
|
||||
|
||||
// setup the CSRF field
|
||||
tc.CSRF = csrfError
|
||||
if r != nil {
|
||||
tc.CSRF = csrf.TemplateField(r)
|
||||
}
|
||||
|
||||
// build the menu
|
||||
tc.Menu = tpl.buildMenu(r)
|
||||
|
||||
// build the breadcrumbs
|
||||
tc.BaseContextGaps = bcg.clone()
|
||||
last := len(tc.Crumbs) - 1
|
||||
for i := range tc.Crumbs {
|
||||
tc.Crumbs[i].Active = i == last
|
||||
}
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
// DoInitCheck is called by the template to check that the BaseContext was initialized properly
|
||||
func (bc BaseContext) DoInitCheck() template.HTML {
|
||||
if !bc.inited {
|
||||
return initError
|
||||
}
|
||||
if bc.requestWasNil {
|
||||
return requestNilError
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BaseFormContext combines BaseContext and FormContext
|
||||
type BaseFormContext struct {
|
||||
BaseContext
|
||||
httpx.FormContext
|
||||
}
|
||||
3
internal/dis/component/server/templates/footer.html
Normal file
3
internal/dis/component/server/templates/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>
|
||||
49
internal/dis/component/server/templates/menu.go
Normal file
49
internal/dis/component/server/templates/menu.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package templates
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
139
internal/dis/component/server/templates/new.go
Normal file
139
internal/dis/component/server/templates/new.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
)
|
||||
|
||||
// Parsed represents a parsed template that receives an underlying context of type C
|
||||
type Parsed[C any] struct {
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// Parse creates a new Parsed from a template source.
|
||||
// Parse calls panic() when parsing fails.
|
||||
func Parse[C any](name string, source []byte, Assets assets.Assets) Parsed[C] {
|
||||
return Parsed[C]{
|
||||
template: Assets.MustParseShared(name, string(source)),
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare prepares this template for use inside a concrete handler.
|
||||
// gaps must either be of length 0 or length 1 and may pre-fill gaps to be used when executing the template later.
|
||||
func (p *Parsed[C]) Prepare(tpl *Templating, gaps ...BaseContextGaps) *Template[C] {
|
||||
wrap := Template[C]{
|
||||
tpl: tpl,
|
||||
template: tpl.Template(p.template),
|
||||
}
|
||||
if len(gaps) > 1 {
|
||||
panic("WrapTemplate: must provide either 1 or no gaps")
|
||||
}
|
||||
if len(gaps) == 1 {
|
||||
wrap.gaps = gaps[0]
|
||||
}
|
||||
return &wrap
|
||||
}
|
||||
|
||||
// Tempalte represents an executable template.
|
||||
type Template[C any] struct {
|
||||
tpl *Templating
|
||||
template *template.Template
|
||||
gaps BaseContextGaps
|
||||
}
|
||||
|
||||
// Template returns a template that, if executed together with the context by the Context method, produces the desired result.
|
||||
func (tw *Template[C]) Template() *template.Template {
|
||||
return tw.template
|
||||
}
|
||||
|
||||
// Context generates a context for a given request that can be used to execute the provided template.
|
||||
func (tw *Template[C]) Context(r *http.Request, c C, gaps ...BaseContextGaps) any {
|
||||
// make the gaps something
|
||||
if len(gaps) > 1 {
|
||||
panic("Context: must provide either 1 or no gaps")
|
||||
}
|
||||
|
||||
// update the context with gaps
|
||||
{
|
||||
g := tw.gaps
|
||||
if len(gaps) == 1 {
|
||||
g = gaps[0]
|
||||
}
|
||||
tw.tpl.update(&c, r, g)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseForm is like Parse[BaseFormContext]
|
||||
var ParseForm = Parse[BaseFormContext]
|
||||
|
||||
// FormTemplateContext returns a new handler for a form with the given base context
|
||||
func FormTemplateContext(tw *Template[BaseFormContext]) func(ctx httpx.FormContext, r *http.Request) any {
|
||||
return func(ctx httpx.FormContext, r *http.Request) any {
|
||||
return tw.Context(r, BaseFormContext{FormContext: ctx})
|
||||
}
|
||||
}
|
||||
|
||||
// MappedHandler returns a new handler that maps the incoming context via f
|
||||
func MappedHandler[In, Out any](tw *Template[Out], f func(ctx In, r *http.Request) (Out, BaseContextGaps)) func(ctx In, r *http.Request) any {
|
||||
// TODO: Should this one be removed?
|
||||
return func(ctx In, r *http.Request) any {
|
||||
c, g := f(ctx, r)
|
||||
return tw.Context(r, c, g)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.HandlerWithGaps(func(r *http.Request, gaps *BaseContextGaps) (C, error) {
|
||||
return f(r)
|
||||
})
|
||||
}
|
||||
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandlerWithGaps works like handler, but additionally receives a gaps object to update.
|
||||
func (tw *Template[C]) HandlerWithGaps(f func(r *http.Request, gaps *BaseContextGaps) (C, error)) func(r *http.Request) (any, error) {
|
||||
// TODO: Drop this variant?
|
||||
var zero C
|
||||
return func(r *http.Request) (any, error) {
|
||||
g := tw.gaps.clone()
|
||||
c, err := f(r, &g)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// update the context
|
||||
return tw.Context(r, c, g), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *Template[C]) HTMLHandlerWithGaps(f func(r *http.Request, gaps *BaseContextGaps) (C, error)) httpx.HTMLHandler[any] {
|
||||
return httpx.HTMLHandler[any]{
|
||||
Handler: tw.HandlerWithGaps(f),
|
||||
Template: tw.Template(),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes this template with the given context
|
||||
func (tw *Template[C]) Execute(w http.ResponseWriter, r *http.Request, c C, gaps ...BaseContextGaps) error {
|
||||
return tw.ExecuteWithError(w, r, c, nil, gaps...)
|
||||
}
|
||||
|
||||
// ExecuteWithError executes this template, or the default error handler if err != nil
|
||||
func (tw *Template[C]) ExecuteWithError(w http.ResponseWriter, r *http.Request, c C, err error, gaps ...BaseContextGaps) error {
|
||||
// TODO: Drop this variant?
|
||||
// TODO: This should be removed!
|
||||
return httpx.WriteHTML(tw.Context(r, c, gaps...), err, tw.template, "", w, r)
|
||||
}
|
||||
65
internal/dis/component/server/templates/template.go
Normal file
65
internal/dis/component/server/templates/template.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"text/template/parse"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
|
||||
)
|
||||
|
||||
const (
|
||||
footerName = "@custom/footer"
|
||||
aboutName = "@custom/about"
|
||||
)
|
||||
|
||||
//go:embed "footer.html"
|
||||
var footerTemplateStr string
|
||||
var defaultFooterTemplate = template.Must(template.New("footer.html").Parse(footerTemplateStr))
|
||||
|
||||
// Template creates a copy of template with shared template parts updated accordingly.
|
||||
// Any template using this should use one of the template contexts in this package.
|
||||
func (tpl *Templating) Template(t *template.Template) *template.Template {
|
||||
// TODO: This should not be used!
|
||||
|
||||
// create a clone of the template
|
||||
clone := template.Must(t.Clone())
|
||||
|
||||
// add all the fixed parse trees
|
||||
footerTree := tpl.getTemplateAsset(defaultFooterTemplate)
|
||||
template.Must(clone.AddParseTree(footerName, footerTree))
|
||||
|
||||
// optionally add the about asset
|
||||
if aboutTree := tpl.readTemplateAsset("about.html"); clone.Lookup(aboutName) != nil && aboutTree != nil {
|
||||
template.Must(clone.AddParseTree(aboutName, aboutTree))
|
||||
}
|
||||
return clone // and return the tree
|
||||
}
|
||||
|
||||
// getTemplateAsset returns an overridable template asset.
|
||||
//
|
||||
// If the asset named can successfully be parsed, it is returned.
|
||||
// If it can not be parsed, the default template is returned.
|
||||
func (tpl *Templating) getTemplateAsset(dflt *template.Template) *parse.Tree {
|
||||
tree := tpl.readTemplateAsset(dflt.Name())
|
||||
if tree == nil {
|
||||
return dflt.Tree.Copy()
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
// readTemplateAsset is like getTemplateAssets, but takes an explicit name to read.
|
||||
// when the asset does not exist, or cannot be opened, returns nil.
|
||||
func (tpl *Templating) readTemplateAsset(name string) *parse.Tree {
|
||||
template, 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 nil
|
||||
}
|
||||
return template.Tree
|
||||
}
|
||||
21
internal/dis/component/server/templates/templating.go
Normal file
21
internal/dis/component/server/templates/templating.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package templates
|
||||
|
||||
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