initial commit and release
This commit is contained in:
commit
a8a93ab5ae
218
hrender.go
Normal file
218
hrender.go
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
H map[string]any
|
||||||
|
|
||||||
|
HRender struct {
|
||||||
|
fs fs.FS
|
||||||
|
enableCache bool
|
||||||
|
funcMap template.FuncMap
|
||||||
|
cache map[string]*template.Template
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewHTMLRender initializes a new Render instance.
|
||||||
|
// fileSystem: The file system containing the templates (usually embed.FS or os.DirFS).
|
||||||
|
// enableCache: If true, templates will be compiled once and cached for future use.
|
||||||
|
func NewHTMLRender(fileSystem fs.FS, enableCache bool) *HRender {
|
||||||
|
return &HRender{
|
||||||
|
fs: fileSystem,
|
||||||
|
enableCache: enableCache,
|
||||||
|
cache: make(map[string]*template.Template),
|
||||||
|
funcMap: template.FuncMap{
|
||||||
|
"dict": Dict,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFunc registers a custom function to the template's FuncMap.
|
||||||
|
// This must be called before rendering any templates if you want the function to be available.
|
||||||
|
func (r *HRender) AddFunc(name string, fn any) {
|
||||||
|
r.funcMap[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render executes the template for the given pageName and writes the result to w.
|
||||||
|
// pageName: The path to the page template (e.g., "pages/login.html").
|
||||||
|
// data: The data map to be passed to the template.
|
||||||
|
// layoutName: (Optional) The path to the layout template (e.g., "layouts/base.html").
|
||||||
|
//
|
||||||
|
// If caching is enabled, it checks the cache first. If not found (or cache disabled),
|
||||||
|
// it compiles the template (merging layout and page) and caches it if appropriate.
|
||||||
|
func (r *HRender) Render(w http.ResponseWriter, pageName string, data H, layoutName ...string) error {
|
||||||
|
if !strings.HasSuffix(pageName, ".html") {
|
||||||
|
pageName += ".html"
|
||||||
|
}
|
||||||
|
|
||||||
|
var layout string
|
||||||
|
if len(layoutName) > 0 && layoutName[0] != "" {
|
||||||
|
layout = layoutName[0]
|
||||||
|
if !strings.HasSuffix(layout, ".html") {
|
||||||
|
layout += ".html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := pageName
|
||||||
|
if layout != "" {
|
||||||
|
cacheKey = layout + "::" + pageName
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if r.enableCache {
|
||||||
|
r.cacheMu.RLock()
|
||||||
|
cachedTmpl, ok := r.cache[cacheKey]
|
||||||
|
r.cacheMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return r.execute(w, cachedTmpl, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.cacheMu.Lock()
|
||||||
|
if cachedTmpl, ok = r.cache[cacheKey]; ok {
|
||||||
|
r.cacheMu.Unlock()
|
||||||
|
return r.execute(w, cachedTmpl, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err = r.buildTemplate(pageName, layout)
|
||||||
|
if err != nil {
|
||||||
|
r.cacheMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.cache[cacheKey] = tmpl
|
||||||
|
slog.Debug("template compiled and cached", "key", cacheKey)
|
||||||
|
r.cacheMu.Unlock()
|
||||||
|
|
||||||
|
} else {
|
||||||
|
tmpl, err = r.buildTemplate(pageName, layout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.execute(w, tmpl, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute runs the template into a buffer first to catch any execution errors
|
||||||
|
// before writing to the response writer. This prevents partial writes on error.
|
||||||
|
func (r *HRender) execute(w http.ResponseWriter, tmpl *template.Template, data H) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := buf.WriteTo(w)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTemplate constructs a new template instance by:
|
||||||
|
// 1. Reading the page content.
|
||||||
|
// 2. Reading the layout content (if provided) and embedding the page content into it using {{ embed }}.
|
||||||
|
// 3. Walking the file system to parse all other available templates (fragments/components) so they can be invoked.
|
||||||
|
func (r *HRender) buildTemplate(pageName, layoutName string) (*template.Template, error) {
|
||||||
|
pageContent, err := fs.ReadFile(r.fs, pageName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("page not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalContent string
|
||||||
|
|
||||||
|
if layoutName != "" {
|
||||||
|
layoutBytes, err := fs.ReadFile(r.fs, layoutName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("layout not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutStr := string(layoutBytes)
|
||||||
|
|
||||||
|
layoutStr = strings.ReplaceAll(layoutStr, "{{ embed }}", string(pageContent))
|
||||||
|
layoutStr = strings.ReplaceAll(layoutStr, "{{embed}}", string(pageContent))
|
||||||
|
|
||||||
|
finalContent = layoutStr
|
||||||
|
} else {
|
||||||
|
finalContent = string(pageContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := template.New("root").Funcs(r.funcMap)
|
||||||
|
|
||||||
|
if _, err := tmpl.Parse(finalContent); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing main content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fs.WalkDir(r.fs, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".html") && !strings.HasSuffix(path, ".gohtml") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == pageName || path == layoutName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(r.fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.ToSlash(path)
|
||||||
|
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
|
||||||
|
_, err = tmpl.New(name).Parse(string(content))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dict creates a map[string]any from a list of key-value pairs.
|
||||||
|
// It expects an even number of arguments, where every odd argument is a string key
|
||||||
|
// and the following argument is its value.
|
||||||
|
//
|
||||||
|
// This is particularly useful for passing multiple pieces of data to a sub-template
|
||||||
|
// or fragment, as Go templates normally only accept a single pipeline argument.
|
||||||
|
//
|
||||||
|
// Usage in template:
|
||||||
|
//
|
||||||
|
// {{ template "fragments/input" dict "Label" "Username" "Value" .User.Name "Type" "text" }}
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A map[string]any containing the key-value pairs.
|
||||||
|
// - A map with an "error" key if the number of arguments is odd or a key is not a string.
|
||||||
|
func Dict(values ...any) map[string]any {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return map[string]any{
|
||||||
|
"error": "invalid dict call",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dict := make(map[string]any, len(values)/2)
|
||||||
|
for i := 0; i < len(values); i += 2 {
|
||||||
|
key, ok := values[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{
|
||||||
|
"error": "dict keys must be strings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dict[key] = values[i+1]
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
229
hrender_test.go
Normal file
229
hrender_test.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mockFS fstest.MapFS
|
||||||
|
data H
|
||||||
|
page string
|
||||||
|
layout string
|
||||||
|
customFuncs template.FuncMap
|
||||||
|
expectedError bool
|
||||||
|
expectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple page",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<h1>hello world</h1>`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.html",
|
||||||
|
expectedBody: "<h1>hello world</h1>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple page with layout",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<h1>hello world</h1>`),
|
||||||
|
},
|
||||||
|
"main-layout.html": {
|
||||||
|
Data: []byte(`<main>{{ embed }}</main>`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.html",
|
||||||
|
layout: "main-layout.html",
|
||||||
|
expectedBody: "<main><h1>hello world</h1></main>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple page with fragment",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<h1>{{ template "fragments/header" . }}</h1>`),
|
||||||
|
},
|
||||||
|
"fragments/header.html": {
|
||||||
|
Data: []byte(`Hello Header`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.html",
|
||||||
|
expectedBody: "<h1>Hello Header</h1>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple page with layout and fragment",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"layout.html": {
|
||||||
|
Data: []byte(`<body>{{ embed }}</body>`),
|
||||||
|
},
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<div>{{ template "fragments/footer" . }}</div>`),
|
||||||
|
},
|
||||||
|
"fragments/footer.html": {
|
||||||
|
Data: []byte(`Footer Content`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.html",
|
||||||
|
layout: "layout.html",
|
||||||
|
expectedBody: "<body><div>Footer Content</div></body>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple page with Dict function and fragment",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<button {{ template "fragments/button_attrs" (dict "ID" "myButton" "Class" "btn btn-primary") }}>Click Me</button>`),
|
||||||
|
},
|
||||||
|
"fragments/button_attrs.html": {
|
||||||
|
Data: []byte(`id="{{.ID}}" class="{{.Class}}"`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.html",
|
||||||
|
expectedBody: `<button id="myButton" class="btn btn-primary">Click Me</button>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error page not found due to wrong extension",
|
||||||
|
mockFS: fstest.MapFS{
|
||||||
|
"index.another": {
|
||||||
|
Data: []byte(`<h1>Hello, wrong extension!</h1>`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: "index.another",
|
||||||
|
expectedError: true,
|
||||||
|
expectedBody: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := NewHTMLRender(tt.mockFS, false)
|
||||||
|
|
||||||
|
for name, fn := range tt.customFuncs {
|
||||||
|
r.AddFunc(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
var layouts []string
|
||||||
|
if tt.layout != "" {
|
||||||
|
layouts = append(layouts, tt.layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.Render(rec, tt.page, tt.data, layouts...)
|
||||||
|
if tt.expectedError {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec.Body.String()); got != tt.expectedBody {
|
||||||
|
t.Errorf("expected body %q, got %q", tt.expectedBody, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Dict(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
given []any
|
||||||
|
expected map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid - all strings",
|
||||||
|
given: []any{"key", "value"},
|
||||||
|
expected: map[string]any{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - key string value int",
|
||||||
|
given: []any{"age", 15},
|
||||||
|
expected: map[string]any{
|
||||||
|
"age": 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error invalid dict call",
|
||||||
|
given: []any{"error"},
|
||||||
|
expected: map[string]any{
|
||||||
|
"error": "invalid dict call",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error dict keys must be strings",
|
||||||
|
given: []any{1, "error"},
|
||||||
|
expected: map[string]any{
|
||||||
|
"error": "dict keys must be strings",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := Dict(tt.given...)
|
||||||
|
if !reflect.DeepEqual(got, tt.expected) {
|
||||||
|
t.Errorf("expected %v, got %v", tt.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Cache(t *testing.T) {
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"index.html": {
|
||||||
|
Data: []byte(`<h1>Hello Cached World</h1>`),
|
||||||
|
},
|
||||||
|
"main-layout.html": {
|
||||||
|
Data: []byte(`<main>{{ embed }}</main>`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
originalLogger := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
defer slog.SetDefault(originalLogger)
|
||||||
|
|
||||||
|
r := NewHTMLRender(mockFS, true)
|
||||||
|
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
err1 := r.Render(rec1, "index.html", nil, "main-layout.html")
|
||||||
|
if err1 != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err1)
|
||||||
|
}
|
||||||
|
expected := "<main><h1>Hello Cached World</h1></main>"
|
||||||
|
if got := strings.TrimSpace(rec1.Body.String()); got != expected {
|
||||||
|
t.Errorf("expected body %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), "template compiled and cached") {
|
||||||
|
t.Error("expected 'template compiled and cached' message on first render")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
err2 := r.Render(rec2, "index.html", nil, "main-layout.html")
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err2)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec2.Body.String()); got != expected {
|
||||||
|
t.Errorf("expected body %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), "template compiled and cached") {
|
||||||
|
t.Error("did not expect 'template compiled and cached' message on second render")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user