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 PageWelcome 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