Compare commits
2 Commits
c7d08d8004
...
0c9a701cef
Author | SHA1 | Date | |
---|---|---|---|
0c9a701cef | |||
2efa1e0d07 |
@ -41,7 +41,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
|||||||
(?, 'David', 'Server lag is really bad right now...'),
|
(?, 'David', 'Server lag is really bad right now...'),
|
||||||
(?, 'Eve', 'Quick question about spell mechanics')`
|
(?, 'Eve', 'Quick question about spell mechanics')`
|
||||||
|
|
||||||
timestamps := []interface{}{
|
timestamps := []any{
|
||||||
now - 3600*6, // 6 hours ago
|
now - 3600*6, // 6 hours ago
|
||||||
now - 3600*4, // 4 hours ago
|
now - 3600*4, // 4 hours ago
|
||||||
now - 3600*2, // 2 hours ago
|
now - 3600*2, // 2 hours ago
|
||||||
|
@ -45,7 +45,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
|||||||
(?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'),
|
(?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'),
|
||||||
(?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')`
|
(?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')`
|
||||||
|
|
||||||
timestamps := []interface{}{
|
timestamps := []any{
|
||||||
now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago
|
now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago
|
||||||
now - 86400*6, now - 86400*6, // Reply 1
|
now - 86400*6, now - 86400*6, // Reply 1
|
||||||
now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1)
|
now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1)
|
||||||
|
@ -40,7 +40,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
|||||||
(3, ?, 'Fourth post from admin'),
|
(3, ?, 'Fourth post from admin'),
|
||||||
(2, ?, 'Fifth post - maintenance notice')`
|
(2, ?, 'Fifth post - maintenance notice')`
|
||||||
|
|
||||||
timestamps := []interface{}{
|
timestamps := []any{
|
||||||
now - 86400*7, // 1 week ago
|
now - 86400*7, // 1 week ago
|
||||||
now - 86400*5, // 5 days ago
|
now - 86400*5, // 5 days ago
|
||||||
now - 86400*2, // 2 days ago
|
now - 86400*2, // 2 days ago
|
||||||
|
@ -3,17 +3,72 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"dk/internal/router"
|
||||||
|
"dk/internal/template"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Start() error {
|
func Start(port string) error {
|
||||||
// Simple fasthttp server for now
|
// Initialize template cache - use current working directory for development
|
||||||
requestHandler := func(ctx *fasthttp.RequestCtx) {
|
cwd, err := os.Getwd()
|
||||||
ctx.SetContentType("text/html; charset=utf-8")
|
if err != nil {
|
||||||
fmt.Fprintf(ctx, "<h1>Dragon Knight</h1><p>Server is running!</p>")
|
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Server starting on :8080")
|
data := map[string]any{
|
||||||
return fasthttp.ListenAndServe(":8080", requestHandler)
|
"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)
|
||||||
}
|
}
|
75
internal/template/doc.go
Normal file
75
internal/template/doc.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// 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
|
358
internal/template/template.go
Normal file
358
internal/template/template.go
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
templates map[string]*Template
|
||||||
|
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 {
|
||||||
|
if basePath == "" {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
basePath = "."
|
||||||
|
} else {
|
||||||
|
basePath = filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Cache{
|
||||||
|
templates: make(map[string]*Template),
|
||||||
|
basePath: basePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Load(name string) (*Template, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
tmpl, exists := c.templates[name]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
if err := c.checkAndReload(tmpl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.loadFromFile(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) loadFromFile(name string) (*Template, error) {
|
||||||
|
filePath := filepath.Join(c.basePath, "templates", name)
|
||||||
|
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("template file not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := &Template{
|
||||||
|
name: name,
|
||||||
|
content: string(content),
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
filePath: filePath,
|
||||||
|
cache: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.templates[name] = tmpl
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) checkAndReload(tmpl *Template) error {
|
||||||
|
info, err := os.Stat(tmpl.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.ModTime().After(tmpl.modTime) {
|
||||||
|
content, err := os.ReadFile(tmpl.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
tmpl.content = string(content)
|
||||||
|
tmpl.modTime = info.ModTime()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
result = t.replaceDotNotation(result, data)
|
||||||
|
|
||||||
|
result = t.processYield(result, opts)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Template) replaceDotNotation(content string, data map[string]any) string {
|
||||||
|
result := content
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
startIdx := strings.Index(result[start:], "{")
|
||||||
|
if startIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
startIdx += start
|
||||||
|
|
||||||
|
endIdx := strings.Index(result[startIdx:], "}")
|
||||||
|
if endIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIdx += startIdx
|
||||||
|
|
||||||
|
placeholder := result[startIdx+1 : endIdx]
|
||||||
|
|
||||||
|
if strings.Contains(placeholder, ".") {
|
||||||
|
value := t.getNestedValue(data, placeholder)
|
||||||
|
if value != nil {
|
||||||
|
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
||||||
|
start = startIdx + len(fmt.Sprintf("%v", value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = endIdx + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Template) getNestedValue(data map[string]any, path string) any {
|
||||||
|
keys := strings.Split(path, ".")
|
||||||
|
current := data
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
return current[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
next, ok := current[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := next.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
current = v
|
||||||
|
case map[any]any:
|
||||||
|
newMap := make(map[string]any)
|
||||||
|
for k, val := range v {
|
||||||
|
newMap[fmt.Sprintf("%v", k)] = val
|
||||||
|
}
|
||||||
|
current = newMap
|
||||||
|
default:
|
||||||
|
rv := reflect.ValueOf(next)
|
||||||
|
if rv.Kind() == reflect.Map {
|
||||||
|
newMap := make(map[string]any)
|
||||||
|
for _, k := range rv.MapKeys() {
|
||||||
|
newMap[fmt.Sprintf("%v", k.Interface())] = rv.MapIndex(k).Interface()
|
||||||
|
}
|
||||||
|
current = newMap
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.RenderNamedWithOptions(opts, v)
|
||||||
|
case []any:
|
||||||
|
result = t.RenderPositionalWithOptions(opts, v...)
|
||||||
|
default:
|
||||||
|
rv := reflect.ValueOf(data)
|
||||||
|
if rv.Kind() == reflect.Slice {
|
||||||
|
args := make([]any, rv.Len())
|
||||||
|
for i := 0; i < rv.Len(); i++ {
|
||||||
|
args[i] = rv.Index(i).Interface()
|
||||||
|
}
|
||||||
|
result = t.RenderPositionalWithOptions(opts, args...)
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
348
internal/template/template_test.go
Normal file
348
internal/template/template_test.go
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCache(t *testing.T) {
|
||||||
|
cache := NewCache("")
|
||||||
|
if cache == nil {
|
||||||
|
t.Fatal("NewCache returned nil")
|
||||||
|
}
|
||||||
|
if cache.templates == nil {
|
||||||
|
t.Fatal("templates map not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPositionalReplacement(t *testing.T) {
|
||||||
|
tmpl := &Template{
|
||||||
|
name: "test",
|
||||||
|
content: "Hello {0}, you are {1} years old!",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tmpl.RenderPositional("Alice", 25)
|
||||||
|
expected := "Hello Alice, you are 25 years old!"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamedReplacement(t *testing.T) {
|
||||||
|
tmpl := &Template{
|
||||||
|
name: "test",
|
||||||
|
content: "Hello {name}, you are {age} years old!",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"name": "Bob",
|
||||||
|
"age": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tmpl.RenderNamed(data)
|
||||||
|
expected := "Hello Bob, you are 30 years old!"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotNotationReplacement(t *testing.T) {
|
||||||
|
tmpl := &Template{
|
||||||
|
name: "test",
|
||||||
|
content: "User: {user.name}, Email: {user.contact.email}",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"name": "Charlie",
|
||||||
|
"contact": map[string]any{
|
||||||
|
"email": "charlie@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tmpl.RenderNamed(data)
|
||||||
|
expected := "User: Charlie, Email: charlie@example.com"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLoadingAndCaching(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateFile := filepath.Join(templatesDir, "test.html")
|
||||||
|
content := "Hello {name}!"
|
||||||
|
err = os.WriteFile(templateFile, []byte(content), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := NewCache(tmpDir)
|
||||||
|
|
||||||
|
tmpl, err := cache.Load("test.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl.content != content {
|
||||||
|
t.Errorf("Expected content %q, got %q", content, tmpl.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl2, err := cache.Load("test.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl != tmpl2 {
|
||||||
|
t.Error("Template should be cached and return same instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateReloading(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateFile := filepath.Join(templatesDir, "test.html")
|
||||||
|
content1 := "Hello {name}!"
|
||||||
|
err = os.WriteFile(templateFile, []byte(content1), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := NewCache(tmpDir)
|
||||||
|
|
||||||
|
tmpl, err := cache.Load("test.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl.content != content1 {
|
||||||
|
t.Errorf("Expected content %q, got %q", content1, tmpl.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
content2 := "Hi {name}, welcome!"
|
||||||
|
err = os.WriteFile(templateFile, []byte(content2), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl2, err := cache.Load("test.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl2.content != content2 {
|
||||||
|
t.Errorf("Expected reloaded content %q, got %q", content2, tmpl2.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNestedValue(t *testing.T) {
|
||||||
|
tmpl := &Template{}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"level1": map[string]any{
|
||||||
|
"level2": map[string]any{
|
||||||
|
"value": "found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tmpl.getNestedValue(data, "level1.level2.value")
|
||||||
|
if result != "found" {
|
||||||
|
t.Errorf("Expected 'found', got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = tmpl.getNestedValue(data, "level1.nonexistent")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("Expected nil for nonexistent path, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMixedReplacementTypes(t *testing.T) {
|
||||||
|
tmpl := &Template{
|
||||||
|
name: "test",
|
||||||
|
content: "Hello {name}, you have {count} {items.type}s!",
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"name": "Dave",
|
||||||
|
"count": 5,
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "apple",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tmpl.RenderNamed(data)
|
||||||
|
expected := "Hello Dave, you have 5 apples!"
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -223,8 +223,8 @@ The builder automatically sets sensible defaults for all fields if not specified
|
|||||||
|
|
||||||
// Get all equipment
|
// Get all equipment
|
||||||
equipment := user.GetEquipment()
|
equipment := user.GetEquipment()
|
||||||
weapon := equipment["weapon"].(map[string]interface{})
|
weapon := equipment["weapon"].(map[string]any)
|
||||||
armor := equipment["armor"].(map[string]interface{})
|
armor := equipment["armor"].(map[string]any)
|
||||||
|
|
||||||
fmt.Printf("Weapon: %s (ID: %d)\n", weapon["name"], weapon["id"])
|
fmt.Printf("Weapon: %s (ID: %d)\n", weapon["name"], weapon["id"])
|
||||||
fmt.Printf("Armor: %s (ID: %d)\n", armor["name"], armor["id"])
|
fmt.Printf("Armor: %s (ID: %d)\n", armor["name"], armor["id"])
|
||||||
|
@ -382,14 +382,14 @@ func (u *User) HasVisitedTown(townID string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetEquipment returns all equipped item information
|
// GetEquipment returns all equipped item information
|
||||||
func (u *User) GetEquipment() map[string]interface{} {
|
func (u *User) GetEquipment() map[string]any {
|
||||||
return map[string]interface{}{
|
return map[string]any{
|
||||||
"weapon": map[string]interface{}{"id": u.WeaponID, "name": u.WeaponName},
|
"weapon": map[string]any{"id": u.WeaponID, "name": u.WeaponName},
|
||||||
"armor": map[string]interface{}{"id": u.ArmorID, "name": u.ArmorName},
|
"armor": map[string]any{"id": u.ArmorID, "name": u.ArmorName},
|
||||||
"shield": map[string]interface{}{"id": u.ShieldID, "name": u.ShieldName},
|
"shield": map[string]any{"id": u.ShieldID, "name": u.ShieldName},
|
||||||
"slot1": map[string]interface{}{"id": u.Slot1ID, "name": u.Slot1Name},
|
"slot1": map[string]any{"id": u.Slot1ID, "name": u.Slot1Name},
|
||||||
"slot2": map[string]interface{}{"id": u.Slot2ID, "name": u.Slot2Name},
|
"slot2": map[string]any{"id": u.Slot2ID, "name": u.Slot2Name},
|
||||||
"slot3": map[string]interface{}{"id": u.Slot3ID, "name": u.Slot3Name},
|
"slot3": map[string]any{"id": u.Slot3ID, "name": u.Slot3Name},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ func setupTestDB(t *testing.T) *database.DB {
|
|||||||
('charlie', 'hashed_pass_3', 'charlie@example.com', 0, 'verify_token_123', ?, ?, 4, 0, 0, 3, 1, 100, 0, 15, 0, 10, 15, 0, 10, 5, 5, 5, 5, '', ''),
|
('charlie', 'hashed_pass_3', 'charlie@example.com', 0, 'verify_token_123', ?, ?, 4, 0, 0, 3, 1, 100, 0, 15, 0, 10, 15, 0, 10, 5, 5, 5, 5, '', ''),
|
||||||
('diana', 'hashed_pass_4', 'diana@example.com', 1, '', ?, ?, 0, 25, -10, 1, 8, 1200, 3500, 35, 25, 15, 35, 25, 15, 12, 10, 15, 12, '1,2,3,6,7', '1,2,3,4')`
|
('diana', 'hashed_pass_4', 'diana@example.com', 1, '', ?, ?, 0, 25, -10, 1, 8, 1200, 3500, 35, 25, 15, 35, 25, 15, 12, 10, 15, 12, '1,2,3,6,7', '1,2,3,4')`
|
||||||
|
|
||||||
timestamps := []interface{}{
|
timestamps := []any{
|
||||||
now - 86400*7, now - 3600*2, // alice: registered 1 week ago, last online 2 hours ago
|
now - 86400*7, now - 3600*2, // alice: registered 1 week ago, last online 2 hours ago
|
||||||
now - 86400*5, now - 86400*1, // bob: registered 5 days ago, last online 1 day ago
|
now - 86400*5, now - 86400*1, // bob: registered 5 days ago, last online 1 day ago
|
||||||
now - 86400*1, now - 86400*1, // charlie: registered 1 day ago, last online 1 day ago
|
now - 86400*1, now - 86400*1, // charlie: registered 1 day ago, last online 1 day ago
|
||||||
@ -644,7 +644,7 @@ func TestGetEquipmentAndStats(t *testing.T) {
|
|||||||
t.Error("Expected non-nil equipment map")
|
t.Error("Expected non-nil equipment map")
|
||||||
}
|
}
|
||||||
|
|
||||||
weapon, ok := equipment["weapon"].(map[string]interface{})
|
weapon, ok := equipment["weapon"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Error("Expected weapon to be a map")
|
t.Error("Expected weapon to be a map")
|
||||||
}
|
}
|
||||||
|
13
main.go
13
main.go
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -10,8 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
var port string
|
||||||
|
flag.StringVar(&port, "p", "3000", "Port to run server on")
|
||||||
|
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
startServer()
|
startServer(port)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,7 +25,8 @@ func main() {
|
|||||||
log.Fatalf("Installation failed: %v", err)
|
log.Fatalf("Installation failed: %v", err)
|
||||||
}
|
}
|
||||||
case "serve":
|
case "serve":
|
||||||
startServer()
|
flag.CommandLine.Parse(os.Args[2:])
|
||||||
|
startServer(port)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||||
fmt.Fprintln(os.Stderr, "Available commands:")
|
fmt.Fprintln(os.Stderr, "Available commands:")
|
||||||
@ -32,9 +37,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServer() {
|
func startServer(port string) {
|
||||||
fmt.Println("Starting Dragon Knight server...")
|
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)
|
log.Fatalf("Server failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user