readysite / website / internal / content / render / funcs.go
14.4 KB
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()
}
← Back