funcs.go
// Package render provides template functions for page rendering.
package render
import (
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"reflect"
"strings"
"time"
"github.com/readysite/readysite/website/internal/access"
"github.com/readysite/readysite/website/internal/helpers"
"github.com/readysite/readysite/website/models"
)
// normalizeQuotes converts smart/curly quotes to straight quotes and fixes
// escaped underscores. This prevents template parse errors when AI generates
// content with fancy quotes or markdown-style escaped underscores.
func normalizeQuotes(s string) string {
// Replace curly double quotes with straight quotes
s = strings.ReplaceAll(s, "\u201C", "\"") // " LEFT DOUBLE QUOTATION MARK
s = strings.ReplaceAll(s, "\u201D", "\"") // " RIGHT DOUBLE QUOTATION MARK
// Replace curly single quotes with straight quotes
s = strings.ReplaceAll(s, "\u2018", "'") // ' LEFT SINGLE QUOTATION MARK
s = strings.ReplaceAll(s, "\u2019", "'") // ' RIGHT SINGLE QUOTATION MARK
// Replace escaped underscores (AI often writes site\_name instead of site_name)
s = strings.ReplaceAll(s, "\\_", "_")
return s
}
// RequestContext provides safe access to HTTP request data in templates.
// Use in templates: {{.req.Query.Get "id"}}, {{.req.Path}}, {{.req.Method}}
// For partials, pass the context: {{partial "nav" .}} then access {{.req.Path}}
type RequestContext struct {
Method string // HTTP method (GET, POST, etc.)
Path string // URL path
Query url.Values // Query parameters
Host string // Host header
}
// NewRequestContext creates a sandboxed request context for templates.
// Only exposes safe, read-only request data (no headers, cookies, or body).
func NewRequestContext(r *http.Request) *RequestContext {
return &RequestContext{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.Query(),
Host: r.Host,
}
}
// Funcs returns template functions for page rendering.
func Funcs() template.FuncMap {
return template.FuncMap{
"documents": Documents,
"document": Document,
"collection": Collection,
"page": Page,
"pages": Pages,
"partial": Partial,
"partials": Partials,
}
}
// Documents returns documents from a collection.
// Usage in templates:
//
// {{range $doc := documents "blog_posts"}}
// <h2>{{$doc.GetString "title"}}</h2>
// {{end}}
//
// With filter:
//
// {{range $doc := documents "blog_posts" "WHERE published = true ORDER BY CreatedAt DESC"}}
func Documents(collectionID string, filters ...string) []*models.Document {
filter := ""
if len(filters) > 0 {
filter = filters[0]
}
query := "WHERE CollectionID = ?"
if filter != "" {
// Append additional WHERE conditions
if len(filter) > 6 && filter[:6] == "WHERE " {
query += " AND " + filter[6:]
} else if len(filter) > 9 && filter[:9] == "ORDER BY " {
query += " " + filter
} else {
query += " AND " + filter
}
}
docs, err := models.Documents.Search(query, collectionID)
if err != nil {
log.Printf("render.Documents: failed to query collection %s: %v", collectionID, err)
return nil
}
return docs
}
// Document returns a single document by ID.
// Usage in templates:
//
// {{with $doc := document "abc123"}}
// <h1>{{$doc.GetString "title"}}</h1>
// {{end}}
func Document(id string) *models.Document {
doc, err := models.Documents.Get(id)
if err != nil {
log.Printf("render.Document: failed to get document %s: %v", id, err)
return nil
}
return doc
}
// Collection returns a collection by ID.
// Usage in templates:
//
// {{with $col := collection "blog_posts"}}
// <h1>{{$col.Name}}</h1>
// <p>{{$col.Description}}</p>
// {{end}}
func Collection(id string) *models.Collection {
col, err := models.Collections.Get(id)
if err != nil {
log.Printf("render.Collection: failed to get collection %s: %v", id, err)
return nil
}
return col
}
// Page returns a page by ID.
// Usage in templates:
//
// {{with $p := page "about"}}
// <h1>{{$p.Title}}</h1>
// {{$p.HTML | html}}
// {{end}}
func Page(id string) *models.Page {
p, err := models.Pages.Get(id)
if err != nil {
log.Printf("render.Page: failed to get page %s: %v", id, err)
return nil
}
return p
}
// Pages returns pages, optionally filtered by parent ID.
// Usage in templates:
//
// All root pages:
//
// {{range $p := pages}}
//
// Children of a specific page:
//
// {{range $p := pages "parent-id"}}
func Pages(parentID ...string) []*models.Page {
var query string
var args []any
if len(parentID) > 0 && parentID[0] != "" {
query = "WHERE ParentID = ? ORDER BY Position, CreatedAt"
args = []any{parentID[0]}
} else {
query = "WHERE ParentID = '' ORDER BY Position, CreatedAt"
}
pages, err := models.Pages.Search(query, args...)
if err != nil {
log.Printf("render.Pages: failed to query pages: %v", err)
return nil
}
return pages
}
// Partial renders a partial's HTML content by ID, executing it as a template.
// Usage in templates:
//
// {{partial "header"}}
// {{partial "footer"}}
// {{partial "nav" .}} <!-- pass data context -->
//
// The partial can use all template functions (documents, page, site_name, etc.)
// and can include other partials via {{partial "other"}}.
// Returns empty string if partial not found or not published.
func Partial(id string, data ...any) template.HTML {
p, err := models.Partials.Get(id)
if err != nil {
log.Printf("render.Partial: failed to get partial %s: %v", id, err)
return ""
}
if !p.Published {
log.Printf("render.Partial: partial %s is not published", id)
return ""
}
// Normalize quotes and parse the partial as a template
html := normalizeQuotes(p.HTML)
tmpl, err := template.New(id).Funcs(AllFuncs()).Parse(html)
if err != nil {
log.Printf("render.Partial: failed to parse partial %s: %v", id, err)
return template.HTML("<!-- partial parse error: " + err.Error() + " -->")
}
// Execute with optional data context
var buf strings.Builder
var ctx any
if len(data) > 0 {
ctx = data[0]
}
if err := tmpl.Execute(&buf, ctx); err != nil {
log.Printf("render.Partial: failed to execute partial %s: %v", id, err)
return template.HTML("<!-- partial execute error: " + err.Error() + " -->")
}
return template.HTML(buf.String())
}
// Partials returns all published partials.
// Usage in templates:
//
// {{range $p := partials}}
// <div>{{$p.Name}}: {{$p.HTML | html}}</div>
// {{end}}
func Partials() []*models.Partial {
partials, err := models.Partials.Search("WHERE Published = true ORDER BY Name")
if err != nil {
log.Printf("render.Partials: failed to query partials: %v", err)
return nil
}
return partials
}
// PublishedPages returns only published pages.
// Usage in templates:
//
// {{range $p := published_pages}}
func PublishedPages(parentID ...string) []*models.Page {
var query string
var args []any
if len(parentID) > 0 && parentID[0] != "" {
query = "WHERE ParentID = ? ORDER BY Position, CreatedAt"
args = []any{parentID[0]}
} else {
query = "WHERE ParentID = '' ORDER BY Position, CreatedAt"
}
allPages, err := models.Pages.Search(query, args...)
if err != nil {
log.Printf("render.PublishedPages: failed to query pages: %v", err)
return nil
}
// Filter to only published pages (pages with published content)
var published []*models.Page
for _, p := range allPages {
if p.IsPublished() {
published = append(published, p)
}
}
return published
}
// SiteName returns the configured site name.
func SiteName() string {
name := helpers.GetSetting(models.SettingSiteName)
if name == "" {
return "My Website"
}
return name
}
// SiteDescription returns the configured site description.
func SiteDescription() string {
return helpers.GetSetting(models.SettingSiteDescription)
}
// AllFuncs returns all template functions including published pages.
func AllFuncs() template.FuncMap {
funcs := Funcs()
funcs["published_pages"] = PublishedPages
funcs["partial"] = Partial
funcs["partials"] = Partials
funcs["site_name"] = SiteName
funcs["site_description"] = SiteDescription
// Time functions
funcs["now"] = time.Now
funcs["date"] = formatDate
// String functions
funcs["html"] = htmlSafe
funcs["substr"] = substr
funcs["truncate"] = truncate
funcs["upper"] = strings.ToUpper
funcs["lower"] = strings.ToLower
funcs["title"] = strings.Title
funcs["join"] = strings.Join
funcs["split"] = strings.Split
funcs["contains"] = strings.Contains
funcs["replace"] = replaceAll
funcs["trim"] = strings.TrimSpace
funcs["hasPrefix"] = strings.HasPrefix
funcs["hasSuffix"] = strings.HasSuffix
// Utility functions
funcs["default"] = defaultValue
funcs["len"] = length
// Math functions
funcs["add"] = add
funcs["sub"] = sub
funcs["mul"] = mul
funcs["div"] = div
funcs["mod"] = mod
funcs["seq"] = seq
// Number formatting
funcs["number"] = formatNumber
funcs["currency"] = formatCurrency
// Search (set via RegisterSearchFunc)
if SearchFunc != nil {
funcs["search"] = SearchFunc
}
return funcs
}
// SearchFunc is a pluggable search function set from main.go to avoid import cycles.
// Set this to a func(query string, args ...int) []search.SearchResult before serving.
var SearchFunc any
// formatDate formats a time.Time using Go's reference time format.
// Usage: {{date "2006-01-02" .CreatedAt}}
func formatDate(format string, t time.Time) string {
return t.Format(format)
}
// htmlSafe marks a string as safe HTML (not escaped).
// Usage: {{.Content | html}}
func htmlSafe(s string) template.HTML {
return template.HTML(s)
}
// substr returns a substring.
// Usage: {{substr .Content 0 100}}
func substr(s string, start, length int) string {
runes := []rune(s)
if start >= len(runes) {
return ""
}
end := start + length
if end > len(runes) {
end = len(runes)
}
if start < 0 {
start = 0
}
return string(runes[start:end])
}
// truncate truncates a string to maxLen characters, adding "..." if truncated.
// Usage: {{truncate 100 .Description}}
func truncate(maxLen int, s string) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}
// defaultValue returns val if it's not nil/empty, otherwise defaultVal.
// Usage: {{default "Untitled" .Title}}
func defaultValue(defaultVal, val any) any {
if val == nil {
return defaultVal
}
// Check for empty string
if s, ok := val.(string); ok && s == "" {
return defaultVal
}
// Check for zero values using reflect
v := reflect.ValueOf(val)
if v.Kind() == reflect.Ptr && v.IsNil() {
return defaultVal
}
return val
}
// length returns the length of a string, slice, array, or map.
// Usage: {{len .Items}}
func length(v any) int {
if v == nil {
return 0
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.String, reflect.Slice, reflect.Array, reflect.Map, reflect.Chan:
return rv.Len()
default:
return 0
}
}
// add returns a + b.
func add(a, b int) int { return a + b }
// sub returns a - b.
func sub(a, b int) int { return a - b }
// mul returns a * b.
func mul(a, b int) int { return a * b }
// div returns a / b (integer division).
func div(a, b int) int {
if b == 0 {
return 0
}
return a / b
}
// mod returns a % b (modulo).
func mod(a, b int) int {
if b == 0 {
return 0
}
return a % b
}
// seq returns a sequence of integers from start to end (inclusive).
// Usage: {{range $i := seq 1 10}}
func seq(start, end int) []int {
if end < start {
return nil
}
result := make([]int, end-start+1)
for i := range result {
result[i] = start + i
}
return result
}
// replaceAll replaces all occurrences of old with new in s.
// Argument order is (old, new, s) to work correctly with template piping:
// Usage: {{.status | replace "_" " "}} -> replaces underscores with spaces
func replaceAll(old, new, s string) string {
return strings.ReplaceAll(s, old, new)
}
// formatNumber formats an integer with thousand separators.
// Usage: {{number 1234567}} -> "1,234,567"
func formatNumber(n any) string {
var num int64
switch v := n.(type) {
case int:
num = int64(v)
case int64:
num = v
case float64:
num = int64(v)
default:
return "0"
}
// Handle negative numbers
negative := num < 0
if negative {
num = -num
}
// Convert to string and add commas
s := fmt.Sprintf("%d", num)
var result strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result.WriteRune(',')
}
result.WriteRune(c)
}
if negative {
return "-" + result.String()
}
return result.String()
}
// formatCurrency formats a number as USD currency.
// Usage: {{currency 1234.56}} -> "$1,234.56"
// Usage: {{currency 1234}} -> "$1,234"
func formatCurrency(n any) string {
var num float64
switch v := n.(type) {
case int:
num = float64(v)
case int64:
num = float64(v)
case float64:
num = v
default:
return "$0"
}
// Handle negative
negative := num < 0
if negative {
num = -num
}
// Format with 2 decimal places if needed
var formatted string
if num == float64(int64(num)) {
formatted = formatNumber(int64(num))
} else {
intPart := int64(num)
decPart := int64((num - float64(intPart)) * 100)
formatted = fmt.Sprintf("%s.%02d", formatNumber(intPart), decPart)
}
if negative {
return "-$" + formatted
}
return "$" + formatted
}
// RenderPage renders a page's HTML content with template functions.
// Pages can use template functions like {{user}}, {{site_name}}, {{documents "posts"}}, etc.
// Returns rendered HTML, or raw HTML if template parsing/execution fails.
func RenderPage(page *models.Page, r *http.Request) string {
html := page.HTML()
// Normalize quotes to prevent template parse errors from AI-generated smart quotes
html = normalizeQuotes(html)
// Get current user for request-specific functions
currentUser := access.GetUserFromJWT(r)
// Create request-specific functions merged with global functions
funcs := template.FuncMap{}
for k, v := range AllFuncs() {
funcs[k] = v
}
// Add request-specific functions
funcs["user"] = func() *models.User { return currentUser }
funcs["req"] = func() *RequestContext { return NewRequestContext(r) }
funcs["query"] = func(key string) string { return r.URL.Query().Get(key) }
funcs["path"] = func() string { return r.URL.Path }
// Parse as template with all functions
tmpl, err := template.New("page").Funcs(funcs).Parse(html)
if err != nil {
log.Printf("render: template parse error for page %s: %v", page.ID, err)
return html
}
// Execute template (no data context needed - everything is via functions)
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
log.Printf("render: template execute error for page %s: %v", page.ID, err)
return html
}
return buf.String()
}