From 0c9a701cef1044857ea1cda22caac7961d50cf22 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Sat, 9 Aug 2025 08:48:24 -0500 Subject: [PATCH] enhance template engine with inheritence, composition --- internal/server/server.go | 71 +++++++++++++-- internal/template/doc.go | 80 +++++++++++++++-- internal/template/template.go | 140 ++++++++++++++++++++++++++++- internal/template/template_test.go | 140 +++++++++++++++++++++++++++++ main.go | 13 ++- 5 files changed, 423 insertions(+), 21 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 0645bcf..4b142a3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, "

Dragon Knight

Server is running!

") +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) } \ No newline at end of file diff --git a/internal/template/doc.go b/internal/template/doc.go index adee161..4624feb 100644 --- a/internal/template/doc.go +++ b/internal/template/doc.go @@ -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 \ No newline at end of file +// 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"} +//

Default content

+// {/block} +// +// Yield - template inheritance points: +// +//
{yield content}
+// +// +// # Template Inheritance Example +// +// layout.html: +// +// +// +// {title} +// {yield content} +// +// +// page.html: +// +// {include "layout.html"} +// {block "content"} +//

Welcome {user.name}!

+// {/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 diff --git a/internal/template/template.go b/internal/template/template.go index da703b3..086aa47 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -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 +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 5d057f3..2e2c88f 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -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 := `{include "header.html"}Hello {name}!` + err = os.WriteFile(filepath.Join(templatesDir, "main.html"), []byte(mainTemplate), 0644) + if err != nil { + t.Fatal(err) + } + + // Create included template + headerTemplate := `{title}` + 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 := "WelcomeHello Alice!" + + 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 := `
{include "partial.html"}
` + 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 := `
{include "partial.html"}
` + + 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}
{yield content}
`, + } + + result := tmpl.RenderNamed(map[string]any{}) + expected := "
Default content
" + + 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 := `{title}{yield content}` + 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"}

Welcome {name}!

This is the content block.

{/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 := `Test Page

Welcome Bob!

This is the content block.

` + + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} diff --git a/main.go b/main.go index f071fc7..d6def0f 100644 --- a/main.go +++ b/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) } } \ No newline at end of file