enhance template engine with inheritence, composition
This commit is contained in:
parent
2efa1e0d07
commit
0c9a701cef
@ -3,17 +3,72 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"dk/internal/router"
|
||||
"dk/internal/template"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func Start() error {
|
||||
// Simple fasthttp server for now
|
||||
requestHandler := func(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
fmt.Fprintf(ctx, "<h1>Dragon Knight</h1><p>Server is running!</p>")
|
||||
func Start(port string) error {
|
||||
// Initialize template cache - use current working directory for development
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Server starting on :8080")
|
||||
return fasthttp.ListenAndServe(":8080", requestHandler)
|
||||
templateCache := template.NewCache(cwd)
|
||||
|
||||
// Initialize router
|
||||
r := router.New()
|
||||
|
||||
// Hello world endpoint
|
||||
r.Get("/", func(ctx router.Ctx, params []string) {
|
||||
tmpl, err := templateCache.Load("hello.html")
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
|
||||
fmt.Fprintf(ctx, "Template error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"title": "Dragon Knight",
|
||||
"message": "Hello World!",
|
||||
}
|
||||
|
||||
tmpl.WriteTo(ctx, data)
|
||||
})
|
||||
|
||||
// Use current working directory for static files
|
||||
assetsDir := filepath.Join(cwd, "assets")
|
||||
|
||||
// Static file server for /assets
|
||||
fs := &fasthttp.FS{
|
||||
Root: assetsDir,
|
||||
Compress: true,
|
||||
}
|
||||
assetsHandler := fs.NewRequestHandler()
|
||||
|
||||
// Combined handler
|
||||
requestHandler := func(ctx *fasthttp.RequestCtx) {
|
||||
path := string(ctx.Path())
|
||||
|
||||
// Handle static assets - strip /assets prefix
|
||||
if len(path) >= 7 && path[:7] == "/assets" {
|
||||
// Strip the /assets prefix for the file system handler
|
||||
originalPath := ctx.Path()
|
||||
ctx.Request.URI().SetPath(path[7:]) // Remove "/assets" prefix
|
||||
assetsHandler(ctx)
|
||||
ctx.Request.URI().SetPathBytes(originalPath) // Restore original path
|
||||
return
|
||||
}
|
||||
|
||||
// Handle routes
|
||||
r.ServeHTTP(ctx)
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
log.Printf("Server starting on %s", addr)
|
||||
return fasthttp.ListenAndServe(addr, requestHandler)
|
||||
}
|
@ -1,5 +1,75 @@
|
||||
// Package template provides in-memory template caching with automatic reloading
|
||||
// and placeholder replacement functionality. Templates are loaded from files
|
||||
// adjacent to the binary and support both positional and named placeholder
|
||||
// replacement with dot notation for accessing nested map values.
|
||||
package template
|
||||
// Package template provides a high-performance template engine with in-memory
|
||||
// caching, automatic reloading, and advanced template composition features.
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// cache := template.NewCache("") // Auto-detects binary location
|
||||
// tmpl, err := cache.Load("page.html")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// data := map[string]any{
|
||||
// "title": "Welcome",
|
||||
// "user": map[string]any{
|
||||
// "name": "Alice",
|
||||
// "email": "alice@example.com",
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// result := tmpl.RenderNamed(data)
|
||||
//
|
||||
// # Placeholder Types
|
||||
//
|
||||
// Named placeholders: {name}, {title}
|
||||
//
|
||||
// Dot notation: {user.name}, {user.contact.email}
|
||||
//
|
||||
// Positional: {0}, {1}, {2}
|
||||
//
|
||||
// # Template Composition
|
||||
//
|
||||
// Includes - embed other templates with data sharing:
|
||||
//
|
||||
// {include "header.html"}
|
||||
// {include "nav.html"}
|
||||
//
|
||||
// Blocks - define reusable content sections:
|
||||
//
|
||||
// {block "content"}
|
||||
// <h1>Default content</h1>
|
||||
// {/block}
|
||||
//
|
||||
// Yield - template inheritance points:
|
||||
//
|
||||
// <main>{yield content}</main>
|
||||
// <footer>{yield footer}</footer>
|
||||
//
|
||||
// # Template Inheritance Example
|
||||
//
|
||||
// layout.html:
|
||||
//
|
||||
// <!DOCTYPE html>
|
||||
// <html>
|
||||
// <head><title>{title}</title></head>
|
||||
// <body>{yield content}</body>
|
||||
// </html>
|
||||
//
|
||||
// page.html:
|
||||
//
|
||||
// {include "layout.html"}
|
||||
// {block "content"}
|
||||
// <h1>Welcome {user.name}!</h1>
|
||||
// {/block}
|
||||
//
|
||||
// # Advanced Features
|
||||
//
|
||||
// Disable includes for partial rendering:
|
||||
//
|
||||
// opts := RenderOptions{ResolveIncludes: false}
|
||||
// chunk := tmpl.RenderNamedWithOptions(opts, data)
|
||||
//
|
||||
// FastHTTP integration:
|
||||
//
|
||||
// tmpl.WriteTo(ctx, data) // Sets content-type and writes response
|
||||
package template
|
||||
|
@ -18,11 +18,17 @@ type Cache struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
type RenderOptions struct {
|
||||
ResolveIncludes bool
|
||||
Blocks map[string]string
|
||||
}
|
||||
|
||||
type Template struct {
|
||||
name string
|
||||
content string
|
||||
modTime time.Time
|
||||
filePath string
|
||||
cache *Cache
|
||||
}
|
||||
|
||||
func NewCache(basePath string) *Cache {
|
||||
@ -74,6 +80,7 @@ func (c *Cache) loadFromFile(name string) (*Template, error) {
|
||||
content: string(content),
|
||||
modTime: info.ModTime(),
|
||||
filePath: filePath,
|
||||
cache: c,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
@ -105,17 +112,37 @@ func (c *Cache) checkAndReload(tmpl *Template) error {
|
||||
}
|
||||
|
||||
func (t *Template) RenderPositional(args ...any) string {
|
||||
return t.RenderPositionalWithOptions(RenderOptions{ResolveIncludes: true}, args...)
|
||||
}
|
||||
|
||||
func (t *Template) RenderPositionalWithOptions(opts RenderOptions, args ...any) string {
|
||||
result := t.content
|
||||
for i, arg := range args {
|
||||
placeholder := fmt.Sprintf("{%d}", i)
|
||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
if opts.ResolveIncludes {
|
||||
result = t.processIncludes(result, nil, opts)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *Template) RenderNamed(data map[string]any) string {
|
||||
return t.RenderNamedWithOptions(RenderOptions{ResolveIncludes: true}, data)
|
||||
}
|
||||
|
||||
func (t *Template) RenderNamedWithOptions(opts RenderOptions, data map[string]any) string {
|
||||
result := t.content
|
||||
|
||||
// Process blocks first to extract them
|
||||
result = t.processBlocks(result, &opts)
|
||||
|
||||
// Process includes next so they get the data substitutions
|
||||
if opts.ResolveIncludes {
|
||||
result = t.processIncludes(result, data, opts)
|
||||
}
|
||||
|
||||
// Apply data substitutions after includes are processed
|
||||
for key, value := range data {
|
||||
placeholder := fmt.Sprintf("{%s}", key)
|
||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
||||
@ -123,6 +150,8 @@ func (t *Template) RenderNamed(data map[string]any) string {
|
||||
|
||||
result = t.replaceDotNotation(result, data)
|
||||
|
||||
result = t.processYield(result, opts)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@ -201,13 +230,17 @@ func (t *Template) getNestedValue(data map[string]any, path string) any {
|
||||
}
|
||||
|
||||
func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) {
|
||||
t.WriteToWithOptions(ctx, data, RenderOptions{ResolveIncludes: true})
|
||||
}
|
||||
|
||||
func (t *Template) WriteToWithOptions(ctx *fasthttp.RequestCtx, data any, opts RenderOptions) {
|
||||
var result string
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
result = t.RenderNamed(v)
|
||||
result = t.RenderNamedWithOptions(opts, v)
|
||||
case []any:
|
||||
result = t.RenderPositional(v...)
|
||||
result = t.RenderPositionalWithOptions(opts, v...)
|
||||
default:
|
||||
rv := reflect.ValueOf(data)
|
||||
if rv.Kind() == reflect.Slice {
|
||||
@ -215,12 +248,111 @@ func (t *Template) WriteTo(ctx *fasthttp.RequestCtx, data any) {
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
args[i] = rv.Index(i).Interface()
|
||||
}
|
||||
result = t.RenderPositional(args...)
|
||||
result = t.RenderPositionalWithOptions(opts, args...)
|
||||
} else {
|
||||
result = t.RenderPositional(data)
|
||||
result = t.RenderPositionalWithOptions(opts, data)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
ctx.WriteString(result)
|
||||
}
|
||||
|
||||
// processIncludes handles {include "template.html"} directives
|
||||
func (t *Template) processIncludes(content string, data map[string]any, opts RenderOptions) string {
|
||||
result := content
|
||||
|
||||
for {
|
||||
start := strings.Index(result, "{include ")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
end := strings.Index(result[start:], "}")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
end += start
|
||||
|
||||
directive := result[start+9:end] // Skip "{include "
|
||||
templateName := strings.Trim(directive, "\" ")
|
||||
|
||||
if includedTemplate, err := t.cache.Load(templateName); err == nil {
|
||||
var includedContent string
|
||||
if data != nil {
|
||||
// Create new options to pass blocks to included template
|
||||
includeOpts := RenderOptions{
|
||||
ResolveIncludes: opts.ResolveIncludes,
|
||||
Blocks: opts.Blocks,
|
||||
}
|
||||
includedContent = includedTemplate.RenderNamedWithOptions(includeOpts, data)
|
||||
} else {
|
||||
includedContent = includedTemplate.content
|
||||
}
|
||||
result = result[:start] + includedContent + result[end+1:]
|
||||
} else {
|
||||
// Remove the include directive if template not found
|
||||
result = result[:start] + result[end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// processYield handles {yield} directives for template inheritance
|
||||
func (t *Template) processYield(content string, opts RenderOptions) string {
|
||||
if opts.Blocks == nil {
|
||||
return strings.ReplaceAll(content, "{yield}", "")
|
||||
}
|
||||
|
||||
result := content
|
||||
for blockName, blockContent := range opts.Blocks {
|
||||
yieldPlaceholder := fmt.Sprintf("{yield %s}", blockName)
|
||||
result = strings.ReplaceAll(result, yieldPlaceholder, blockContent)
|
||||
}
|
||||
|
||||
// Replace any remaining {yield} with empty string
|
||||
result = strings.ReplaceAll(result, "{yield}", "")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// processBlocks extracts {block "name"}...{/block} sections
|
||||
func (t *Template) processBlocks(content string, opts *RenderOptions) string {
|
||||
if opts.Blocks == nil {
|
||||
opts.Blocks = make(map[string]string)
|
||||
}
|
||||
|
||||
result := content
|
||||
|
||||
for {
|
||||
start := strings.Index(result, "{block ")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
nameEnd := strings.Index(result[start:], "}")
|
||||
if nameEnd == -1 {
|
||||
break
|
||||
}
|
||||
nameEnd += start
|
||||
|
||||
blockName := strings.Trim(result[start+7:nameEnd], "\" ")
|
||||
|
||||
contentStart := nameEnd + 1
|
||||
endTag := "{/block}"
|
||||
contentEnd := strings.Index(result[contentStart:], endTag)
|
||||
if contentEnd == -1 {
|
||||
break
|
||||
}
|
||||
contentEnd += contentStart
|
||||
|
||||
blockContent := result[contentStart:contentEnd]
|
||||
opts.Blocks[blockName] = blockContent
|
||||
|
||||
// Remove the block definition from the template
|
||||
result = result[:start] + result[contentEnd+len(endTag):]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -206,3 +206,143 @@ func TestMixedReplacementTypes(t *testing.T) {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeSupport(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "template_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create main template with include
|
||||
mainTemplate := `<html><head>{include "header.html"}</head><body>Hello {name}!</body></html>`
|
||||
err = os.WriteFile(filepath.Join(templatesDir, "main.html"), []byte(mainTemplate), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create included template
|
||||
headerTemplate := `<title>{title}</title><meta charset="utf-8">`
|
||||
err = os.WriteFile(filepath.Join(templatesDir, "header.html"), []byte(headerTemplate), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewCache(tmpDir)
|
||||
tmpl, err := cache.Load("main.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"name": "Alice",
|
||||
"title": "Welcome",
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(data)
|
||||
expected := "<html><head><title>Welcome</title><meta charset=\"utf-8\"></head><body>Hello Alice!</body></html>"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeDisabled(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "template_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mainTemplate := `<div>{include "partial.html"}</div>`
|
||||
err = os.WriteFile(filepath.Join(templatesDir, "main.html"), []byte(mainTemplate), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewCache(tmpDir)
|
||||
tmpl, err := cache.Load("main.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := RenderOptions{ResolveIncludes: false}
|
||||
result := tmpl.RenderNamedWithOptions(opts, map[string]any{})
|
||||
expected := `<div>{include "partial.html"}</div>`
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocksAndYield(t *testing.T) {
|
||||
tmpl := &Template{
|
||||
name: "test",
|
||||
content: `{block "content"}Default content{/block}<main>{yield content}</main>`,
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(map[string]any{})
|
||||
expected := "<main>Default content</main>"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateInheritance(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "template_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
err = os.MkdirAll(templatesDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Layout template
|
||||
layoutTemplate := `<!DOCTYPE html><html><head><title>{title}</title></head><body>{yield content}</body></html>`
|
||||
err = os.WriteFile(filepath.Join(templatesDir, "layout.html"), []byte(layoutTemplate), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Page template that extends layout
|
||||
pageTemplate := `{include "layout.html"}{block "content"}<h1>Welcome {name}!</h1><p>This is the content block.</p>{/block}`
|
||||
err = os.WriteFile(filepath.Join(templatesDir, "page.html"), []byte(pageTemplate), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewCache(tmpDir)
|
||||
tmpl, err := cache.Load("page.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"title": "Test Page",
|
||||
"name": "Bob",
|
||||
}
|
||||
|
||||
result := tmpl.RenderNamed(data)
|
||||
expected := `<!DOCTYPE html><html><head><title>Test Page</title></head><body><h1>Welcome Bob!</h1><p>This is the content block.</p></body></html>`
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
13
main.go
13
main.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -10,8 +11,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
var port string
|
||||
flag.StringVar(&port, "p", "3000", "Port to run server on")
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
startServer()
|
||||
startServer(port)
|
||||
return
|
||||
}
|
||||
|
||||
@ -21,7 +25,8 @@ func main() {
|
||||
log.Fatalf("Installation failed: %v", err)
|
||||
}
|
||||
case "serve":
|
||||
startServer()
|
||||
flag.CommandLine.Parse(os.Args[2:])
|
||||
startServer(port)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||
fmt.Fprintln(os.Stderr, "Available commands:")
|
||||
@ -32,9 +37,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
func startServer(port string) {
|
||||
fmt.Println("Starting Dragon Knight server...")
|
||||
if err := server.Start(); err != nil {
|
||||
if err := server.Start(port); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user