mirror of
https://github.com/semihalev/twig.git
synced 2026-03-14 13:55:46 +01:00
1. Added spaceless filter that removes whitespace between HTML tags
2. Implemented {% apply filter %} ... {% endapply %} tag
3. Updated spaceless tag to use the spaceless filter internally
4. Fixed endverbatim tag handling
5. Added tests for all new functionality
The apply tag allows applying filters to blocks of content, which is the
modern recommended approach in Twig, replacing the deprecated spaceless tag.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2282 lines
54 KiB
Go
2282 lines
54 KiB
Go
package twig
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"math"
|
|
"math/rand"
|
|
"net/url"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// FilterFunc is a function that can be used as a filter
|
|
type FilterFunc func(value interface{}, args ...interface{}) (interface{}, error)
|
|
|
|
// FunctionFunc is a function that can be used in templates
|
|
type FunctionFunc func(args ...interface{}) (interface{}, error)
|
|
|
|
// TestFunc is a function that can be used for testing conditions
|
|
type TestFunc func(value interface{}, args ...interface{}) (bool, error)
|
|
|
|
// OperatorFunc is a function that implements a custom operator
|
|
type OperatorFunc func(left, right interface{}) (interface{}, error)
|
|
|
|
// Extension represents a Twig extension
|
|
type Extension interface {
|
|
// GetName returns the name of the extension
|
|
GetName() string
|
|
|
|
// GetFilters returns the filters defined by this extension
|
|
GetFilters() map[string]FilterFunc
|
|
|
|
// GetFunctions returns the functions defined by this extension
|
|
GetFunctions() map[string]FunctionFunc
|
|
|
|
// GetTests returns the tests defined by this extension
|
|
GetTests() map[string]TestFunc
|
|
|
|
// GetOperators returns the operators defined by this extension
|
|
GetOperators() map[string]OperatorFunc
|
|
|
|
// GetTokenParsers returns any custom token parsers
|
|
GetTokenParsers() []TokenParser
|
|
|
|
// Initialize initializes the extension
|
|
Initialize(*Engine)
|
|
}
|
|
|
|
// TokenParser provides a way to parse custom tags
|
|
type TokenParser interface {
|
|
// GetTag returns the tag this parser handles
|
|
GetTag() string
|
|
|
|
// Parse parses the tag and returns a node
|
|
Parse(*Parser, *Token) (Node, error)
|
|
}
|
|
|
|
// CoreExtension provides the core Twig functionality
|
|
type CoreExtension struct{}
|
|
|
|
// GetName returns the name of the core extension
|
|
func (e *CoreExtension) GetName() string {
|
|
return "core"
|
|
}
|
|
|
|
// GetFilters returns the core filters
|
|
func (e *CoreExtension) GetFilters() map[string]FilterFunc {
|
|
return map[string]FilterFunc{
|
|
"default": e.filterDefault,
|
|
"escape": e.filterEscape,
|
|
"e": e.filterEscape, // alias for escape
|
|
"upper": e.filterUpper,
|
|
"lower": e.filterLower,
|
|
"trim": e.filterTrim,
|
|
"raw": e.filterRaw,
|
|
"length": e.filterLength,
|
|
"count": e.filterLength, // alias for length
|
|
"join": e.filterJoin,
|
|
"split": e.filterSplit,
|
|
"date": e.filterDate,
|
|
"url_encode": e.filterUrlEncode,
|
|
"capitalize": e.filterCapitalize,
|
|
"title": e.filterTitle, // Title case filter
|
|
"first": e.filterFirst,
|
|
"last": e.filterLast,
|
|
"slice": e.filterSlice,
|
|
"reverse": e.filterReverse,
|
|
"sort": e.filterSort,
|
|
"keys": e.filterKeys,
|
|
"merge": e.filterMerge,
|
|
"replace": e.filterReplace,
|
|
"striptags": e.filterStripTags,
|
|
"number_format": e.filterNumberFormat,
|
|
"abs": e.filterAbs,
|
|
"round": e.filterRound,
|
|
"nl2br": e.filterNl2Br,
|
|
"format": e.filterFormat,
|
|
"json_encode": e.filterJsonEncode,
|
|
"spaceless": e.filterSpaceless,
|
|
}
|
|
}
|
|
|
|
// GetFunctions returns the core functions
|
|
func (e *CoreExtension) GetFunctions() map[string]FunctionFunc {
|
|
return map[string]FunctionFunc{
|
|
"range": e.functionRange,
|
|
"date": e.functionDate,
|
|
"random": e.functionRandom,
|
|
"max": e.functionMax,
|
|
"min": e.functionMin,
|
|
"dump": e.functionDump,
|
|
"constant": e.functionConstant,
|
|
"cycle": e.functionCycle,
|
|
"include": e.functionInclude,
|
|
"json_encode": e.functionJsonEncode,
|
|
"length": e.functionLength,
|
|
"merge": e.functionMerge,
|
|
}
|
|
}
|
|
|
|
// GetTests returns the core tests
|
|
func (e *CoreExtension) GetTests() map[string]TestFunc {
|
|
return map[string]TestFunc{
|
|
"defined": e.testDefined,
|
|
"empty": e.testEmpty,
|
|
"null": e.testNull,
|
|
"none": e.testNull, // Alias for null
|
|
"even": e.testEven,
|
|
"odd": e.testOdd,
|
|
"iterable": e.testIterable,
|
|
"same_as": e.testSameAs,
|
|
"divisible_by": e.testDivisibleBy,
|
|
"constant": e.testConstant,
|
|
"equalto": e.testEqualTo,
|
|
"sameas": e.testSameAs, // Alias
|
|
"starts_with": e.testStartsWith,
|
|
"ends_with": e.testEndsWith,
|
|
"matches": e.testMatches,
|
|
}
|
|
}
|
|
|
|
// GetOperators returns the core operators
|
|
func (e *CoreExtension) GetOperators() map[string]OperatorFunc {
|
|
return map[string]OperatorFunc{
|
|
"in": e.operatorIn,
|
|
"not in": e.operatorNotIn,
|
|
"is": e.operatorIs,
|
|
"is not": e.operatorIsNot,
|
|
"not": e.operatorNot, // Add explicit support for 'not' operator
|
|
"matches": e.operatorMatches,
|
|
"starts with": e.operatorStartsWith,
|
|
"ends with": e.operatorEndsWith,
|
|
}
|
|
}
|
|
|
|
// GetTokenParsers returns the core token parsers
|
|
func (e *CoreExtension) GetTokenParsers() []TokenParser {
|
|
return nil
|
|
}
|
|
|
|
// Initialize initializes the core extension
|
|
func (e *CoreExtension) Initialize(engine *Engine) {
|
|
// Nothing to initialize for core extension
|
|
}
|
|
|
|
// CustomExtension provides a simple way to create custom extensions
|
|
type CustomExtension struct {
|
|
Name string
|
|
Filters map[string]FilterFunc
|
|
Functions map[string]FunctionFunc
|
|
Tests map[string]TestFunc
|
|
Operators map[string]OperatorFunc
|
|
InitFunc func(*Engine)
|
|
}
|
|
|
|
// GetName returns the name of the custom extension
|
|
func (e *CustomExtension) GetName() string {
|
|
return e.Name
|
|
}
|
|
|
|
// GetFilters returns the filters defined by this extension
|
|
func (e *CustomExtension) GetFilters() map[string]FilterFunc {
|
|
if e.Filters == nil {
|
|
return make(map[string]FilterFunc)
|
|
}
|
|
return e.Filters
|
|
}
|
|
|
|
// GetFunctions returns the functions defined by this extension
|
|
func (e *CustomExtension) GetFunctions() map[string]FunctionFunc {
|
|
if e.Functions == nil {
|
|
return make(map[string]FunctionFunc)
|
|
}
|
|
return e.Functions
|
|
}
|
|
|
|
// GetTests returns the tests defined by this extension
|
|
func (e *CustomExtension) GetTests() map[string]TestFunc {
|
|
if e.Tests == nil {
|
|
return make(map[string]TestFunc)
|
|
}
|
|
return e.Tests
|
|
}
|
|
|
|
// GetOperators returns the operators defined by this extension
|
|
func (e *CustomExtension) GetOperators() map[string]OperatorFunc {
|
|
if e.Operators == nil {
|
|
return make(map[string]OperatorFunc)
|
|
}
|
|
return e.Operators
|
|
}
|
|
|
|
// GetTokenParsers returns any custom token parsers
|
|
func (e *CustomExtension) GetTokenParsers() []TokenParser {
|
|
return nil // Custom token parsers not supported in this simple extension
|
|
}
|
|
|
|
// Initialize initializes the extension
|
|
func (e *CustomExtension) Initialize(engine *Engine) {
|
|
if e.InitFunc != nil {
|
|
e.InitFunc(engine)
|
|
}
|
|
}
|
|
|
|
// Filter implementations
|
|
|
|
func (e *CoreExtension) filterDefault(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// If no default value is provided, just return the original value
|
|
if len(args) == 0 {
|
|
return value, nil
|
|
}
|
|
|
|
// Get the default value (first argument)
|
|
defaultVal := args[0]
|
|
|
|
// Check if the value is null/nil or empty
|
|
if value == nil || isEmptyValue(value) {
|
|
// For array literals, make sure we return something that's
|
|
// properly recognized as an iterable in a for loop
|
|
if arrayNode, ok := defaultVal.([]interface{}); ok {
|
|
// If we're passing an array literal as default value,
|
|
// ensure it works correctly in for loops
|
|
return arrayNode, nil
|
|
}
|
|
|
|
// For array literals created by the parser using ArrayNode.Evaluate
|
|
if _, ok := defaultVal.([]Node); ok {
|
|
// Convert to []interface{} for consistency
|
|
return defaultVal, nil
|
|
}
|
|
|
|
// For other types of defaults, return as is
|
|
return defaultVal, nil
|
|
}
|
|
|
|
// If we get here, value is defined and not empty, so return it
|
|
return value, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterEscape(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
return escapeHTML(s), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterUpper(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
return strings.ToUpper(s), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterLower(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
return strings.ToLower(s), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterTrim(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
|
|
// Basic trim with no args - just trim whitespace
|
|
if len(args) == 0 {
|
|
return strings.TrimSpace(s), nil
|
|
}
|
|
|
|
// Trim specific characters
|
|
if len(args) > 0 {
|
|
chars := toString(args[0])
|
|
return strings.Trim(s, chars), nil
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterRaw(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// Raw just returns the value without any processing
|
|
return value, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterLength(value interface{}, args ...interface{}) (interface{}, error) {
|
|
return length(value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterJoin(value interface{}, args ...interface{}) (interface{}, error) {
|
|
delimiter := " "
|
|
if len(args) > 0 {
|
|
if d, ok := args[0].(string); ok {
|
|
delimiter = d
|
|
}
|
|
}
|
|
|
|
// Handle nil values gracefully
|
|
if value == nil {
|
|
return "", nil
|
|
}
|
|
|
|
// For slice-like types, convert to interface slice first if needed
|
|
v := reflect.ValueOf(value)
|
|
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
|
|
// Create a proper slice for joining
|
|
newSlice := make([]interface{}, v.Len())
|
|
for i := 0; i < v.Len(); i++ {
|
|
if v.Index(i).CanInterface() {
|
|
newSlice[i] = v.Index(i).Interface()
|
|
} else {
|
|
newSlice[i] = ""
|
|
}
|
|
}
|
|
value = newSlice
|
|
}
|
|
|
|
return join(value, delimiter)
|
|
}
|
|
|
|
func (e *CoreExtension) filterSplit(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// Default delimiter is whitespace
|
|
delimiter := " "
|
|
limit := -1 // No limit by default
|
|
|
|
// Get delimiter from args
|
|
if len(args) > 0 {
|
|
if d, ok := args[0].(string); ok {
|
|
delimiter = d
|
|
}
|
|
}
|
|
|
|
// Get limit from args if provided
|
|
if len(args) > 1 {
|
|
switch l := args[1].(type) {
|
|
case int:
|
|
limit = l
|
|
case float64:
|
|
limit = int(l)
|
|
case string:
|
|
if lnum, err := strconv.Atoi(l); err == nil {
|
|
limit = lnum
|
|
}
|
|
}
|
|
}
|
|
|
|
s := toString(value)
|
|
|
|
// Handle multiple character delimiters (split on any character in the delimiter)
|
|
if len(delimiter) > 1 {
|
|
// Convert delimiter string to a regex character class
|
|
pattern := "[" + regexp.QuoteMeta(delimiter) + "]"
|
|
re := regexp.MustCompile(pattern)
|
|
|
|
if limit > 0 {
|
|
// Manual split with limit
|
|
parts := re.Split(s, limit)
|
|
return parts, nil
|
|
}
|
|
|
|
return re.Split(s, -1), nil
|
|
}
|
|
|
|
// Simple single character delimiter
|
|
if limit > 0 {
|
|
return strings.SplitN(s, delimiter, limit), nil
|
|
}
|
|
|
|
return strings.Split(s, delimiter), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterDate(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// Get the datetime value
|
|
var dt time.Time
|
|
|
|
// Special handling for nil/empty values
|
|
if value == nil {
|
|
// For nil, return current time
|
|
dt = time.Now()
|
|
} else {
|
|
switch v := value.(type) {
|
|
case time.Time:
|
|
dt = v
|
|
// Check if it's a zero time value (0001-01-01 00:00:00)
|
|
if dt.Year() == 1 && dt.Month() == 1 && dt.Day() == 1 && dt.Hour() == 0 && dt.Minute() == 0 && dt.Second() == 0 {
|
|
// Use current time instead of zero time
|
|
dt = time.Now()
|
|
}
|
|
case string:
|
|
// Handle empty strings and "now"
|
|
if v == "" || v == "0" {
|
|
dt = time.Now()
|
|
} else if v == "now" {
|
|
dt = time.Now()
|
|
} else {
|
|
// Try to parse as integer timestamp first
|
|
if timestamp, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
dt = time.Unix(timestamp, 0)
|
|
} else {
|
|
// Try to parse as string using common formats
|
|
var err error
|
|
formats := []string{
|
|
time.RFC3339,
|
|
time.RFC3339Nano,
|
|
time.RFC1123,
|
|
time.RFC1123Z,
|
|
time.RFC822,
|
|
time.RFC822Z,
|
|
time.ANSIC,
|
|
"2006-01-02",
|
|
"2006-01-02 15:04:05",
|
|
"01/02/2006",
|
|
"01/02/2006 15:04:05",
|
|
}
|
|
|
|
// Try each format until one works
|
|
parsed := false
|
|
for _, format := range formats {
|
|
dt, err = time.Parse(format, v)
|
|
if err == nil {
|
|
parsed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !parsed {
|
|
// If nothing worked, fallback to current time
|
|
dt = time.Now()
|
|
}
|
|
}
|
|
}
|
|
case int64:
|
|
// Handle 0 timestamp
|
|
if v == 0 {
|
|
dt = time.Now()
|
|
} else {
|
|
dt = time.Unix(v, 0)
|
|
}
|
|
case int:
|
|
// Handle 0 timestamp
|
|
if v == 0 {
|
|
dt = time.Now()
|
|
} else {
|
|
dt = time.Unix(int64(v), 0)
|
|
}
|
|
case float64:
|
|
// Handle 0 timestamp
|
|
if v == 0 {
|
|
dt = time.Now()
|
|
} else {
|
|
dt = time.Unix(int64(v), 0)
|
|
}
|
|
default:
|
|
// For unknown types, use current time
|
|
dt = time.Now()
|
|
}
|
|
}
|
|
|
|
// Check for format string
|
|
format := "2006-01-02 15:04:05"
|
|
if len(args) > 0 {
|
|
if f, ok := args[0].(string); ok {
|
|
// Convert PHP/Twig date format to Go date format
|
|
format = convertDateFormat(f)
|
|
}
|
|
}
|
|
|
|
return dt.Format(format), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterUrlEncode(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
return url.QueryEscape(s), nil
|
|
}
|
|
|
|
// Function implementations
|
|
|
|
func (e *CoreExtension) functionRange(args ...interface{}) (interface{}, error) {
|
|
// Handle different argument counts (1, 2, or 3 args)
|
|
var start, end, step int
|
|
var err error
|
|
|
|
switch len(args) {
|
|
case 1:
|
|
// Single argument: range(end) -> range from 0 to end
|
|
start = 0
|
|
end, err = toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
step = 1
|
|
case 2:
|
|
// Two arguments: range(start, end) -> range from start to end
|
|
start, err = toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
end, err = toInt(args[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
step = 1
|
|
case 3:
|
|
// Three arguments: range(start, end, step)
|
|
start, err = toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
end, err = toInt(args[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
step, err = toInt(args[2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, errors.New("range function requires 1-3 arguments")
|
|
}
|
|
|
|
if step == 0 {
|
|
return nil, errors.New("step cannot be zero")
|
|
}
|
|
|
|
// Create the result as a slice of interface{} values explicitly
|
|
// Ensure it's always []interface{} for consistent handling in for loops
|
|
result := make([]interface{}, 0)
|
|
|
|
// For compatibility with existing tests, keep the end index inclusive
|
|
if step > 0 {
|
|
// For positive step, include the end value (end is inclusive)
|
|
for i := start; i <= end; i += step {
|
|
result = append(result, i)
|
|
}
|
|
} else {
|
|
// For negative step, include the end value (end is inclusive)
|
|
for i := start; i >= end; i += step {
|
|
result = append(result, i)
|
|
}
|
|
}
|
|
|
|
// Ensure we're returning a non-nil slice that can be used in loops
|
|
if len(result) == 0 {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionDate(args ...interface{}) (interface{}, error) {
|
|
// Default to current time
|
|
dt := time.Now()
|
|
|
|
// Check if a timestamp or date string was provided
|
|
if len(args) > 0 && args[0] != nil {
|
|
switch v := args[0].(type) {
|
|
case time.Time:
|
|
dt = v
|
|
case string:
|
|
if v == "now" {
|
|
// "now" is current time
|
|
dt = time.Now()
|
|
} else {
|
|
// Try to parse string
|
|
var err error
|
|
dt, err = time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
// Try common formats
|
|
formats := []string{
|
|
time.RFC3339,
|
|
time.RFC3339Nano,
|
|
time.RFC1123,
|
|
time.RFC1123Z,
|
|
time.RFC822,
|
|
time.RFC822Z,
|
|
time.ANSIC,
|
|
"2006-01-02",
|
|
"2006-01-02 15:04:05",
|
|
"01/02/2006",
|
|
"01/02/2006 15:04:05",
|
|
}
|
|
|
|
for _, format := range formats {
|
|
dt, err = time.Parse(format, v)
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse date from string: %s", v)
|
|
}
|
|
}
|
|
}
|
|
case int64:
|
|
dt = time.Unix(v, 0)
|
|
case int:
|
|
dt = time.Unix(int64(v), 0)
|
|
case float64:
|
|
dt = time.Unix(int64(v), 0)
|
|
}
|
|
}
|
|
|
|
// If a timezone is specified as second argument
|
|
if len(args) > 1 {
|
|
if tzName, ok := args[1].(string); ok {
|
|
loc, err := time.LoadLocation(tzName)
|
|
if err == nil {
|
|
dt = dt.In(loc)
|
|
}
|
|
}
|
|
}
|
|
|
|
return dt, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionRandom(args ...interface{}) (interface{}, error) {
|
|
// No args - return a random number between 0 and 2147483647 (PHP's RAND_MAX)
|
|
if len(args) == 0 {
|
|
return rand.Int31(), nil
|
|
}
|
|
|
|
// One argument - return 0 through max-1
|
|
if len(args) == 1 {
|
|
max, err := toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if max <= 0 {
|
|
return nil, errors.New("max must be greater than 0")
|
|
}
|
|
|
|
return rand.Intn(max), nil
|
|
}
|
|
|
|
// Two arguments - min and max
|
|
min, err := toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
max, err := toInt(args[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if max <= min {
|
|
return nil, errors.New("max must be greater than min")
|
|
}
|
|
|
|
// Generate a random number in the range [min, max]
|
|
return min + rand.Intn(max-min+1), nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionMax(args ...interface{}) (interface{}, error) {
|
|
if len(args) == 0 {
|
|
return nil, errors.New("max function requires at least one argument")
|
|
}
|
|
|
|
// First, determine if we're comparing strings or numbers
|
|
allStrings := true
|
|
for _, arg := range args {
|
|
_, isString := arg.(string)
|
|
if !isString {
|
|
allStrings = false
|
|
break
|
|
}
|
|
}
|
|
|
|
// If all arguments are strings, compare them lexicographically
|
|
if allStrings {
|
|
var maxStr string
|
|
var initialized bool
|
|
|
|
for _, arg := range args {
|
|
str := arg.(string)
|
|
if !initialized || str > maxStr {
|
|
maxStr = str
|
|
initialized = true
|
|
}
|
|
}
|
|
|
|
return maxStr, nil
|
|
}
|
|
|
|
// Otherwise, treat as numbers
|
|
var max float64
|
|
var initialized bool
|
|
|
|
for i, arg := range args {
|
|
num, err := toFloat64(arg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("argument %d is not a number", i)
|
|
}
|
|
|
|
if !initialized || num > max {
|
|
max = num
|
|
initialized = true
|
|
}
|
|
}
|
|
|
|
return max, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionMin(args ...interface{}) (interface{}, error) {
|
|
if len(args) == 0 {
|
|
return nil, errors.New("min function requires at least one argument")
|
|
}
|
|
|
|
// First, determine if we're comparing strings or numbers
|
|
allStrings := true
|
|
for _, arg := range args {
|
|
_, isString := arg.(string)
|
|
if !isString {
|
|
allStrings = false
|
|
break
|
|
}
|
|
}
|
|
|
|
// If all arguments are strings, compare them lexicographically
|
|
if allStrings {
|
|
var minStr string
|
|
var initialized bool
|
|
|
|
for _, arg := range args {
|
|
str := arg.(string)
|
|
if !initialized || str < minStr {
|
|
minStr = str
|
|
initialized = true
|
|
}
|
|
}
|
|
|
|
return minStr, nil
|
|
}
|
|
|
|
// Otherwise, treat as numbers
|
|
var min float64
|
|
var initialized bool
|
|
|
|
for i, arg := range args {
|
|
num, err := toFloat64(arg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("argument %d is not a number", i)
|
|
}
|
|
|
|
if !initialized || num < min {
|
|
min = num
|
|
initialized = true
|
|
}
|
|
}
|
|
|
|
return min, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionDump(args ...interface{}) (interface{}, error) {
|
|
if len(args) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var result strings.Builder
|
|
for i, arg := range args {
|
|
if i > 0 {
|
|
result.WriteString(", ")
|
|
}
|
|
result.WriteString(fmt.Sprintf("%#v", arg))
|
|
}
|
|
|
|
return result.String(), nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionConstant(args ...interface{}) (interface{}, error) {
|
|
// Not applicable in Go, but included for compatibility
|
|
return nil, errors.New("constant function not supported in Go")
|
|
}
|
|
|
|
// Test implementations
|
|
|
|
func (e *CoreExtension) testDefined(value interface{}, args ...interface{}) (bool, error) {
|
|
// If the value is nil, it's not defined
|
|
if value == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Default case: the value exists
|
|
return true, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testEmpty(value interface{}, args ...interface{}) (bool, error) {
|
|
return isEmptyValue(value), nil
|
|
}
|
|
|
|
func (e *CoreExtension) testNull(value interface{}, args ...interface{}) (bool, error) {
|
|
return value == nil, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testEven(value interface{}, args ...interface{}) (bool, error) {
|
|
i, err := toInt(value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return i%2 == 0, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testOdd(value interface{}, args ...interface{}) (bool, error) {
|
|
i, err := toInt(value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return i%2 != 0, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testIterable(value interface{}, args ...interface{}) (bool, error) {
|
|
return isIterable(value), nil
|
|
}
|
|
|
|
func (e *CoreExtension) testSameAs(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("same_as test requires an argument")
|
|
}
|
|
return value == args[0], nil
|
|
}
|
|
|
|
func (e *CoreExtension) testDivisibleBy(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("divisible_by test requires a divisor argument")
|
|
}
|
|
|
|
dividend, err := toInt(value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
divisor, err := toInt(args[0])
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if divisor == 0 {
|
|
return false, errors.New("division by zero")
|
|
}
|
|
|
|
return dividend%divisor == 0, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testConstant(value interface{}, args ...interface{}) (bool, error) {
|
|
// Not applicable in Go, but included for compatibility
|
|
return false, errors.New("constant test not supported in Go")
|
|
}
|
|
|
|
func (e *CoreExtension) testEqualTo(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("equalto test requires an argument")
|
|
}
|
|
|
|
// Get the comparison value
|
|
compareWith := args[0]
|
|
|
|
// Convert to strings and compare
|
|
str1 := toString(value)
|
|
str2 := toString(compareWith)
|
|
|
|
return str1 == str2, nil
|
|
}
|
|
|
|
func (e *CoreExtension) testStartsWith(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("starts_with test requires a prefix argument")
|
|
}
|
|
|
|
// Convert to strings
|
|
str := toString(value)
|
|
prefix := toString(args[0])
|
|
|
|
return strings.HasPrefix(str, prefix), nil
|
|
}
|
|
|
|
func (e *CoreExtension) testEndsWith(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("ends_with test requires a suffix argument")
|
|
}
|
|
|
|
// Convert to strings
|
|
str := toString(value)
|
|
suffix := toString(args[0])
|
|
|
|
return strings.HasSuffix(str, suffix), nil
|
|
}
|
|
|
|
func (e *CoreExtension) testMatches(value interface{}, args ...interface{}) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, errors.New("matches test requires a pattern argument")
|
|
}
|
|
|
|
// Convert to strings
|
|
str := toString(value)
|
|
pattern := toString(args[0])
|
|
|
|
// Remove any surrounding slashes (Twig style pattern) if present
|
|
if len(pattern) >= 2 && pattern[0] == '/' && pattern[len(pattern)-1] == '/' {
|
|
pattern = pattern[1 : len(pattern)-1]
|
|
}
|
|
|
|
// Compile the regex
|
|
regex, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid regular expression: %s", err)
|
|
}
|
|
|
|
return regex.MatchString(str), nil
|
|
}
|
|
|
|
// Operator implementations
|
|
|
|
func (e *CoreExtension) operatorIn(left, right interface{}) (interface{}, error) {
|
|
if !isIterable(right) {
|
|
return false, errors.New("right operand must be iterable")
|
|
}
|
|
|
|
return contains(right, left)
|
|
}
|
|
|
|
func (e *CoreExtension) operatorNotIn(left, right interface{}) (interface{}, error) {
|
|
if !isIterable(right) {
|
|
return false, errors.New("right operand must be iterable")
|
|
}
|
|
|
|
result, err := contains(right, left)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return !result, nil
|
|
}
|
|
|
|
func (e *CoreExtension) operatorIs(left, right interface{}) (interface{}, error) {
|
|
// Special case for "is defined" test
|
|
if testName, ok := right.(string); ok && testName == "defined" {
|
|
// The actual check happens in the evaluateExpression method
|
|
// This just returns true to indicate we're handling it
|
|
return true, nil
|
|
}
|
|
|
|
// For all other cases, do simple equality check
|
|
return left == right, nil
|
|
}
|
|
|
|
func (e *CoreExtension) operatorIsNot(left, right interface{}) (interface{}, error) {
|
|
// The 'is not' operator is the negation of 'is'
|
|
equal, err := e.operatorIs(left, right)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return !(equal.(bool)), nil
|
|
}
|
|
|
|
// Helper for bool conversion
|
|
func toBool(value interface{}) bool {
|
|
if value == nil {
|
|
return false
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case int, int8, int16, int32, int64:
|
|
return v != 0
|
|
case uint, uint8, uint16, uint32, uint64:
|
|
return v != 0
|
|
case float32, float64:
|
|
return v != 0
|
|
case string:
|
|
return v != ""
|
|
case []interface{}:
|
|
return len(v) > 0
|
|
case map[string]interface{}:
|
|
return len(v) > 0
|
|
}
|
|
|
|
// Default to true for non-nil values
|
|
return true
|
|
}
|
|
|
|
// operatorNot implements the 'not' operator
|
|
func (e *CoreExtension) operatorNot(left, right interface{}) (interface{}, error) {
|
|
// Special case for "not defined" test
|
|
if testName, ok := right.(string); ok && testName == "defined" {
|
|
// The actual check happens during evaluation in RenderContext
|
|
// This just returns true to indicate we're handling it
|
|
return true, nil
|
|
}
|
|
|
|
// For all other cases, just negate the boolean value of the right operand
|
|
return !toBool(right), nil
|
|
}
|
|
|
|
func (e *CoreExtension) operatorMatches(left, right interface{}) (interface{}, error) {
|
|
// Convert to strings
|
|
str := toString(left)
|
|
pattern := toString(right)
|
|
|
|
// Compile the regex
|
|
regex, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid regular expression: %s", err)
|
|
}
|
|
|
|
return regex.MatchString(str), nil
|
|
}
|
|
|
|
func (e *CoreExtension) operatorStartsWith(left, right interface{}) (interface{}, error) {
|
|
// Convert to strings
|
|
str := toString(left)
|
|
prefix := toString(right)
|
|
|
|
return strings.HasPrefix(str, prefix), nil
|
|
}
|
|
|
|
func (e *CoreExtension) operatorEndsWith(left, right interface{}) (interface{}, error) {
|
|
// Convert to strings
|
|
str := toString(left)
|
|
suffix := toString(right)
|
|
|
|
return strings.HasSuffix(str, suffix), nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func isEmptyValue(v interface{}) bool {
|
|
if v == nil {
|
|
return true
|
|
}
|
|
|
|
switch value := v.(type) {
|
|
case string:
|
|
return value == ""
|
|
case bool:
|
|
return !value
|
|
case int, int8, int16, int32, int64:
|
|
return value == 0
|
|
case uint, uint8, uint16, uint32, uint64:
|
|
return value == 0
|
|
case float32, float64:
|
|
return value == 0
|
|
case []interface{}:
|
|
return len(value) == 0
|
|
case map[string]interface{}:
|
|
return len(value) == 0
|
|
}
|
|
|
|
// Use reflection for other types
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Array, reflect.Slice, reflect.Map:
|
|
return rv.Len() == 0
|
|
case reflect.Bool:
|
|
return !rv.Bool()
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return rv.Int() == 0
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return rv.Uint() == 0
|
|
case reflect.Float32, reflect.Float64:
|
|
return rv.Float() == 0
|
|
case reflect.String:
|
|
return rv.String() == ""
|
|
}
|
|
|
|
// Default behavior for other types
|
|
return false
|
|
}
|
|
|
|
func isIterable(v interface{}) bool {
|
|
if v == nil {
|
|
return false
|
|
}
|
|
|
|
switch v.(type) {
|
|
case string, []interface{}, map[string]interface{}:
|
|
return true
|
|
}
|
|
|
|
// Use reflection for other types
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func length(v interface{}) (int, error) {
|
|
if v == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
switch value := v.(type) {
|
|
case string:
|
|
return len(value), nil
|
|
case []interface{}:
|
|
return len(value), nil
|
|
case map[string]interface{}:
|
|
return len(value), nil
|
|
}
|
|
|
|
// Use reflection for other types
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
|
|
return rv.Len(), nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("cannot get length of %T", v)
|
|
}
|
|
|
|
func join(v interface{}, delimiter string) (string, error) {
|
|
var items []string
|
|
|
|
if v == nil {
|
|
return "", nil
|
|
}
|
|
|
|
// Handle different types
|
|
switch value := v.(type) {
|
|
case []string:
|
|
return strings.Join(value, delimiter), nil
|
|
case []interface{}:
|
|
for _, item := range value {
|
|
items = append(items, toString(item))
|
|
}
|
|
default:
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Array, reflect.Slice:
|
|
for i := 0; i < rv.Len(); i++ {
|
|
items = append(items, toString(rv.Index(i).Interface()))
|
|
}
|
|
default:
|
|
return toString(v), nil
|
|
}
|
|
}
|
|
|
|
return strings.Join(items, delimiter), nil
|
|
}
|
|
|
|
func contains(container, item interface{}) (bool, error) {
|
|
if container == nil {
|
|
return false, nil
|
|
}
|
|
|
|
itemStr := toString(item)
|
|
|
|
// Handle different container types
|
|
switch c := container.(type) {
|
|
case string:
|
|
return strings.Contains(c, itemStr), nil
|
|
case []interface{}:
|
|
for _, v := range c {
|
|
if toString(v) == itemStr {
|
|
return true, nil
|
|
}
|
|
}
|
|
case map[string]interface{}:
|
|
for k := range c {
|
|
if k == itemStr {
|
|
return true, nil
|
|
}
|
|
}
|
|
default:
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(container)
|
|
switch rv.Kind() {
|
|
case reflect.String:
|
|
return strings.Contains(rv.String(), itemStr), nil
|
|
case reflect.Array, reflect.Slice:
|
|
for i := 0; i < rv.Len(); i++ {
|
|
if toString(rv.Index(i).Interface()) == itemStr {
|
|
return true, nil
|
|
}
|
|
}
|
|
case reflect.Map:
|
|
for _, key := range rv.MapKeys() {
|
|
if toString(key.Interface()) == itemStr {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func toString(v interface{}) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
|
|
switch val := v.(type) {
|
|
case string:
|
|
return val
|
|
case int:
|
|
return strconv.Itoa(val)
|
|
case int64:
|
|
return strconv.FormatInt(val, 10)
|
|
case float64:
|
|
return strconv.FormatFloat(val, 'f', -1, 64)
|
|
case bool:
|
|
return strconv.FormatBool(val)
|
|
case []byte:
|
|
return string(val)
|
|
case fmt.Stringer:
|
|
return val.String()
|
|
}
|
|
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
func toInt(v interface{}) (int, error) {
|
|
if v == nil {
|
|
return 0, errors.New("cannot convert nil to int")
|
|
}
|
|
|
|
switch val := v.(type) {
|
|
case int:
|
|
return val, nil
|
|
case int64:
|
|
return int(val), nil
|
|
case float64:
|
|
return int(val), nil
|
|
case string:
|
|
i, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return i, nil
|
|
case bool:
|
|
if val {
|
|
return 1, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("cannot convert %T to int", v)
|
|
}
|
|
|
|
func toFloat64(v interface{}) (float64, error) {
|
|
if v == nil {
|
|
return 0, errors.New("cannot convert nil to float64")
|
|
}
|
|
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return val, nil
|
|
case float32:
|
|
return float64(val), nil
|
|
case int:
|
|
return float64(val), nil
|
|
case int64:
|
|
return float64(val), nil
|
|
case string:
|
|
f, err := strconv.ParseFloat(val, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return f, nil
|
|
case bool:
|
|
if val {
|
|
return 1, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("cannot convert %T to float64", v)
|
|
}
|
|
|
|
// Helper function to convert PHP/Twig date format to Go date format
|
|
func convertDateFormat(format string) string {
|
|
replacements := map[string]string{
|
|
// Day
|
|
"d": "02", // Day of the month, 2 digits with leading zeros
|
|
"D": "Mon", // A textual representation of a day, three letters
|
|
"j": "2", // Day of the month without leading zeros
|
|
"l": "Monday", // A full textual representation of the day of the week
|
|
|
|
// Month
|
|
"F": "January", // A full textual representation of a month
|
|
"m": "01", // Numeric representation of a month, with leading zeros
|
|
"M": "Jan", // A short textual representation of a month, three letters
|
|
"n": "1", // Numeric representation of a month, without leading zeros
|
|
|
|
// Year
|
|
"Y": "2006", // A full numeric representation of a year, 4 digits
|
|
"y": "06", // A two digit representation of a year
|
|
|
|
// Time
|
|
"a": "pm", // Lowercase Ante meridiem and Post meridiem
|
|
"A": "PM", // Uppercase Ante meridiem and Post meridiem
|
|
"g": "3", // 12-hour format of an hour without leading zeros
|
|
"G": "15", // 24-hour format of an hour without leading zeros
|
|
"h": "03", // 12-hour format of an hour with leading zeros
|
|
"H": "15", // 24-hour format of an hour with leading zeros
|
|
"i": "04", // Minutes with leading zeros
|
|
"s": "05", // Seconds with leading zeros
|
|
}
|
|
|
|
result := format
|
|
for phpFormat, goFormat := range replacements {
|
|
result = strings.ReplaceAll(result, phpFormat, goFormat)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Additional filter implementations
|
|
|
|
func (e *CoreExtension) filterCapitalize(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
if s == "" {
|
|
return "", nil
|
|
}
|
|
|
|
words := strings.Fields(s)
|
|
for i, word := range words {
|
|
if len(word) > 0 {
|
|
words[i] = strings.ToUpper(word[0:1]) + strings.ToLower(word[1:])
|
|
}
|
|
}
|
|
|
|
return strings.Join(words, " "), nil
|
|
}
|
|
|
|
// filterTitle implements a title case filter (similar to capitalize but for all words)
|
|
func (e *CoreExtension) filterTitle(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
if s == "" {
|
|
return "", nil
|
|
}
|
|
|
|
words := strings.Fields(s)
|
|
for i, word := range words {
|
|
if len(word) > 0 {
|
|
words[i] = strings.ToUpper(word[0:1]) + strings.ToLower(word[1:])
|
|
}
|
|
}
|
|
|
|
return strings.Join(words, " "), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterFirst(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
if len(v) > 0 {
|
|
return string(v[0]), nil
|
|
}
|
|
return "", nil
|
|
case []interface{}:
|
|
if len(v) > 0 {
|
|
return v[0], nil
|
|
}
|
|
return nil, nil
|
|
case map[string]interface{}:
|
|
for _, val := range v {
|
|
return val, nil // Return first value found
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
switch rv.Kind() {
|
|
case reflect.String:
|
|
s := rv.String()
|
|
if len(s) > 0 {
|
|
return string(s[0]), nil
|
|
}
|
|
return "", nil
|
|
case reflect.Array, reflect.Slice:
|
|
if rv.Len() > 0 {
|
|
return rv.Index(0).Interface(), nil
|
|
}
|
|
return nil, nil
|
|
case reflect.Map:
|
|
for _, key := range rv.MapKeys() {
|
|
return rv.MapIndex(key).Interface(), nil // Return first value found
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot get first element of %T", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterLast(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
if len(v) > 0 {
|
|
return string(v[len(v)-1]), nil
|
|
}
|
|
return "", nil
|
|
case []interface{}:
|
|
if len(v) > 0 {
|
|
return v[len(v)-1], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
switch rv.Kind() {
|
|
case reflect.String:
|
|
s := rv.String()
|
|
if len(s) > 0 {
|
|
return string(s[len(s)-1]), nil
|
|
}
|
|
return "", nil
|
|
case reflect.Array, reflect.Slice:
|
|
if rv.Len() > 0 {
|
|
return rv.Index(rv.Len() - 1).Interface(), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot get last element of %T", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterReverse(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
// Reverse string
|
|
runes := []rune(v)
|
|
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
|
|
runes[i], runes[j] = runes[j], runes[i]
|
|
}
|
|
return string(runes), nil
|
|
case []interface{}:
|
|
// Reverse slice
|
|
result := make([]interface{}, len(v))
|
|
for i, j := 0, len(v)-1; j >= 0; i, j = i+1, j-1 {
|
|
result[i] = v[j]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
switch rv.Kind() {
|
|
case reflect.String:
|
|
s := rv.String()
|
|
runes := []rune(s)
|
|
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
|
|
runes[i], runes[j] = runes[j], runes[i]
|
|
}
|
|
return string(runes), nil
|
|
case reflect.Array, reflect.Slice:
|
|
// Create a new slice with the same type
|
|
resultSlice := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
|
for i, j := 0, rv.Len()-1; j >= 0; i, j = i+1, j-1 {
|
|
resultSlice.Index(i).Set(rv.Index(j))
|
|
}
|
|
return resultSlice.Interface(), nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot reverse %T", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterSlice(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Need at least the start index
|
|
if len(args) < 1 {
|
|
return nil, errors.New("slice filter requires at least one argument (start index)")
|
|
}
|
|
|
|
start, err := toInt(args[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Default length is to the end
|
|
length := -1
|
|
if len(args) > 1 {
|
|
// Make sure we can convert the second argument to an integer
|
|
if args[1] != nil {
|
|
length, err = toInt(args[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
runes := []rune(v)
|
|
runeCount := len(runes)
|
|
|
|
// Handle negative start index
|
|
if start < 0 {
|
|
// In Twig, negative start means count from the end of the string
|
|
// For example, -5 means "the last 5 characters"
|
|
// So we convert it to a positive index directly
|
|
start = runeCount + start
|
|
}
|
|
|
|
// Check bounds
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start >= runeCount {
|
|
return "", nil
|
|
}
|
|
|
|
// Calculate end index
|
|
end := runeCount
|
|
if length >= 0 {
|
|
end = start + length
|
|
if end > runeCount {
|
|
end = runeCount
|
|
}
|
|
} else if length < 0 {
|
|
// Negative length means count from the end
|
|
end = runeCount + length
|
|
if end < start {
|
|
end = start
|
|
}
|
|
}
|
|
|
|
return string(runes[start:end]), nil
|
|
case []interface{}:
|
|
count := len(v)
|
|
|
|
// Handle negative start index
|
|
if start < 0 {
|
|
start = count + start
|
|
}
|
|
|
|
// Check bounds
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start >= count {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// Calculate end index
|
|
end := count
|
|
if length >= 0 {
|
|
end = start + length
|
|
if end > count {
|
|
end = count
|
|
}
|
|
} else if length < 0 {
|
|
// Negative length means count from the end
|
|
end = count + length
|
|
if end < start {
|
|
end = start
|
|
}
|
|
}
|
|
|
|
return v[start:end], nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
switch rv.Kind() {
|
|
case reflect.String:
|
|
s := rv.String()
|
|
runes := []rune(s)
|
|
runeCount := len(runes)
|
|
|
|
// Handle negative start index
|
|
if start < 0 {
|
|
start = runeCount + start
|
|
}
|
|
|
|
// Check bounds
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start >= runeCount {
|
|
return "", nil
|
|
}
|
|
|
|
// Calculate end index
|
|
end := runeCount
|
|
if length >= 0 {
|
|
end = start + length
|
|
if end > runeCount {
|
|
end = runeCount
|
|
}
|
|
} else if length < 0 {
|
|
// Negative length means count from the end
|
|
end = runeCount + length
|
|
if end < start {
|
|
end = start
|
|
}
|
|
}
|
|
|
|
return string(runes[start:end]), nil
|
|
case reflect.Array, reflect.Slice:
|
|
count := rv.Len()
|
|
|
|
// Handle negative start index
|
|
if start < 0 {
|
|
start = count + start
|
|
}
|
|
|
|
// Check bounds
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start >= count {
|
|
return reflect.MakeSlice(rv.Type(), 0, 0).Interface(), nil
|
|
}
|
|
|
|
// Calculate end index
|
|
end := count
|
|
if length >= 0 {
|
|
end = start + length
|
|
if end > count {
|
|
end = count
|
|
}
|
|
} else if length < 0 {
|
|
// Negative length means count from the end
|
|
end = count + length
|
|
if end < start {
|
|
end = start
|
|
}
|
|
}
|
|
|
|
// Create a new slice with the same type
|
|
result := reflect.MakeSlice(rv.Type(), end-start, end-start)
|
|
for i := start; i < end; i++ {
|
|
result.Index(i - start).Set(rv.Index(i))
|
|
}
|
|
|
|
return result.Interface(), nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot slice %T", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterKeys(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case map[string]interface{}:
|
|
keys := make([]string, 0, len(v))
|
|
for k := range v {
|
|
keys = append(keys, k)
|
|
}
|
|
// Sort keys for consistent output
|
|
sort.Strings(keys)
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
if rv.Kind() == reflect.Map {
|
|
// For maps, return the keys as a slice of the same type as the keys
|
|
keys := make([]interface{}, 0, rv.Len())
|
|
for _, key := range rv.MapKeys() {
|
|
if key.CanInterface() {
|
|
keys = append(keys, key.Interface())
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// If it's a pointer, dereference it and try again
|
|
if rv.Kind() == reflect.Ptr && !rv.IsNil() {
|
|
return e.filterKeys(rv.Elem().Interface(), args...)
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot get keys from %T, expected map", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterMerge(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// Handle merging arrays/slices
|
|
rv := reflect.ValueOf(value)
|
|
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array {
|
|
result := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
|
|
|
// Copy original values
|
|
for i := 0; i < rv.Len(); i++ {
|
|
result.Index(i).Set(rv.Index(i))
|
|
}
|
|
|
|
// Add values from the arguments
|
|
for _, arg := range args {
|
|
argRv := reflect.ValueOf(arg)
|
|
if argRv.Kind() == reflect.Slice || argRv.Kind() == reflect.Array {
|
|
// Create a new slice with expanded capacity
|
|
newResult := reflect.MakeSlice(rv.Type(), result.Len()+argRv.Len(), result.Len()+argRv.Len())
|
|
|
|
// Copy existing values
|
|
for i := 0; i < result.Len(); i++ {
|
|
newResult.Index(i).Set(result.Index(i))
|
|
}
|
|
|
|
// Append the new values
|
|
for i := 0; i < argRv.Len(); i++ {
|
|
newResult.Index(result.Len() + i).Set(argRv.Index(i))
|
|
}
|
|
|
|
result = newResult
|
|
}
|
|
}
|
|
|
|
return result.Interface(), nil
|
|
}
|
|
|
|
// Handle merging maps
|
|
if rv.Kind() == reflect.Map {
|
|
// Create a new map with the same key and value types
|
|
resultMap := reflect.MakeMap(rv.Type())
|
|
|
|
// Copy original values
|
|
for _, key := range rv.MapKeys() {
|
|
resultMap.SetMapIndex(key, rv.MapIndex(key))
|
|
}
|
|
|
|
// Merge values from the arguments
|
|
for _, arg := range args {
|
|
argRv := reflect.ValueOf(arg)
|
|
if argRv.Kind() == reflect.Map {
|
|
for _, key := range argRv.MapKeys() {
|
|
resultMap.SetMapIndex(key, argRv.MapIndex(key))
|
|
}
|
|
}
|
|
}
|
|
|
|
return resultMap.Interface(), nil
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterReplace(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
|
|
if len(args) < 2 {
|
|
return s, errors.New("replace filter requires at least 2 arguments (search and replace values)")
|
|
}
|
|
|
|
// Get search and replace values
|
|
search := toString(args[0])
|
|
replace := toString(args[1])
|
|
|
|
return strings.ReplaceAll(s, search, replace), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterStripTags(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
|
|
// Very simple regexp-based HTML tag removal
|
|
re := regexp.MustCompile("<[^>]*>")
|
|
return re.ReplaceAllString(s, ""), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterSort(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Special handling for string slices - convert to []interface{} for consistent handling in for loops
|
|
switch v := value.(type) {
|
|
case []string:
|
|
result := make([]string, len(v))
|
|
copy(result, v)
|
|
sort.Strings(result)
|
|
|
|
// Convert to []interface{} for consistent type handling in for loops
|
|
interfaceSlice := make([]interface{}, len(result))
|
|
for i, val := range result {
|
|
interfaceSlice[i] = val
|
|
}
|
|
return interfaceSlice, nil
|
|
case []int:
|
|
result := make([]int, len(v))
|
|
copy(result, v)
|
|
sort.Ints(result)
|
|
|
|
// Convert to []interface{} for consistent type handling in for loops
|
|
interfaceSlice := make([]interface{}, len(result))
|
|
for i, val := range result {
|
|
interfaceSlice[i] = val
|
|
}
|
|
return interfaceSlice, nil
|
|
case []float64:
|
|
result := make([]float64, len(v))
|
|
copy(result, v)
|
|
sort.Float64s(result)
|
|
|
|
// Convert to []interface{} for consistent type handling in for loops
|
|
interfaceSlice := make([]interface{}, len(result))
|
|
for i, val := range result {
|
|
interfaceSlice[i] = val
|
|
}
|
|
return interfaceSlice, nil
|
|
case []interface{}:
|
|
// Try to determine the type of elements
|
|
if len(v) == 0 {
|
|
return v, nil
|
|
}
|
|
|
|
// For test case compatibility with expected behavior in TestArrayFilters,
|
|
// always sort by string representation for mixed types
|
|
// This ensures [3, '1', 2, '10'] sorts as ['1', '10', '2', '3']
|
|
result := make([]interface{}, len(v))
|
|
copy(result, v)
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return toString(result[i]) < toString(result[j])
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
// Try reflection for other types
|
|
rv := reflect.ValueOf(value)
|
|
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array {
|
|
result := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
|
for i := 0; i < rv.Len(); i++ {
|
|
result.Index(i).Set(rv.Index(i))
|
|
}
|
|
|
|
// Use sort.SliceStable for a stable sort
|
|
sort.SliceStable(result.Interface(), func(i, j int) bool {
|
|
a := result.Index(i).Interface()
|
|
b := result.Index(j).Interface()
|
|
|
|
// Always sort by string representation for consistency
|
|
return toString(a) < toString(b)
|
|
})
|
|
|
|
return result.Interface(), nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot sort %T", value)
|
|
}
|
|
|
|
func (e *CoreExtension) filterNumberFormat(value interface{}, args ...interface{}) (interface{}, error) {
|
|
num, err := toFloat64(value)
|
|
if err != nil {
|
|
return value, nil
|
|
}
|
|
|
|
// Default parameters
|
|
decimals := 0
|
|
decPoint := "."
|
|
thousandsSep := ","
|
|
|
|
// Parse parameters
|
|
if len(args) > 0 {
|
|
if d, err := toInt(args[0]); err == nil {
|
|
decimals = d
|
|
}
|
|
}
|
|
|
|
if len(args) > 1 {
|
|
if d, ok := args[1].(string); ok {
|
|
decPoint = d
|
|
}
|
|
}
|
|
|
|
if len(args) > 2 {
|
|
if t, ok := args[2].(string); ok {
|
|
thousandsSep = t
|
|
}
|
|
}
|
|
|
|
// Format the number
|
|
format := "%." + strconv.Itoa(decimals) + "f"
|
|
str := fmt.Sprintf(format, num)
|
|
|
|
// Split into integer and fractional parts
|
|
parts := strings.Split(str, ".")
|
|
intPart := parts[0]
|
|
|
|
// Handle negative numbers specially
|
|
isNegative := false
|
|
if strings.HasPrefix(intPart, "-") {
|
|
isNegative = true
|
|
intPart = intPart[1:] // Remove negative sign for processing
|
|
}
|
|
|
|
// Add thousands separator
|
|
if thousandsSep != "" {
|
|
// Insert thousands separator
|
|
var buf bytes.Buffer
|
|
for i, char := range intPart {
|
|
if i > 0 && (len(intPart)-i)%3 == 0 {
|
|
buf.WriteString(thousandsSep)
|
|
}
|
|
buf.WriteRune(char)
|
|
}
|
|
intPart = buf.String()
|
|
}
|
|
|
|
// Add back negative sign if needed
|
|
if isNegative {
|
|
intPart = "-" + intPart
|
|
}
|
|
|
|
// Add decimal point and fractional part if needed
|
|
if decimals > 0 {
|
|
if len(parts) > 1 {
|
|
return intPart + decPoint + parts[1], nil
|
|
} else {
|
|
// Add zeros if needed
|
|
zeros := strings.Repeat("0", decimals)
|
|
return intPart + decPoint + zeros, nil
|
|
}
|
|
}
|
|
|
|
return intPart, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterAbs(value interface{}, args ...interface{}) (interface{}, error) {
|
|
num, err := toFloat64(value)
|
|
if err != nil {
|
|
return value, nil
|
|
}
|
|
|
|
return math.Abs(num), nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterRound(value interface{}, args ...interface{}) (interface{}, error) {
|
|
num, err := toFloat64(value)
|
|
if err != nil {
|
|
return value, nil
|
|
}
|
|
|
|
precision := 0
|
|
method := "common"
|
|
|
|
// Parse precision argument
|
|
if len(args) > 0 {
|
|
if p, err := toInt(args[0]); err == nil {
|
|
precision = p
|
|
}
|
|
}
|
|
|
|
// Parse rounding method argument
|
|
if len(args) > 1 {
|
|
if m, ok := args[1].(string); ok {
|
|
method = strings.ToLower(m)
|
|
}
|
|
}
|
|
|
|
// Apply rounding
|
|
var result float64
|
|
switch method {
|
|
case "ceil", "ceiling":
|
|
shift := math.Pow(10, float64(precision))
|
|
result = math.Ceil(num*shift) / shift
|
|
case "floor":
|
|
shift := math.Pow(10, float64(precision))
|
|
result = math.Floor(num*shift) / shift
|
|
default: // "common" or any other value
|
|
shift := math.Pow(10, float64(precision))
|
|
result = math.Round(num*shift) / shift
|
|
}
|
|
|
|
// If precision is 0, return an integer
|
|
if precision == 0 {
|
|
return int(result), nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *CoreExtension) filterNl2Br(value interface{}, args ...interface{}) (interface{}, error) {
|
|
s := toString(value)
|
|
|
|
// Replace newlines with <br> (HTML5 style, no self-closing slash)
|
|
s = strings.ReplaceAll(s, "\r\n", "<br>")
|
|
s = strings.ReplaceAll(s, "\n", "<br>")
|
|
s = strings.ReplaceAll(s, "\r", "<br>")
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// New function implementations
|
|
|
|
func (e *CoreExtension) functionCycle(args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, errors.New("cycle function requires at least two arguments (values to cycle through and position)")
|
|
}
|
|
|
|
// The first argument should be the array of values to cycle through
|
|
var values []interface{}
|
|
var position int
|
|
|
|
// Check if the first argument is an array
|
|
firstArg := args[0]
|
|
if firstArgVal := reflect.ValueOf(firstArg); firstArgVal.Kind() == reflect.Slice || firstArgVal.Kind() == reflect.Array {
|
|
// Extract values from the array
|
|
values = make([]interface{}, firstArgVal.Len())
|
|
for i := 0; i < firstArgVal.Len(); i++ {
|
|
values[i] = firstArgVal.Index(i).Interface()
|
|
}
|
|
|
|
// Position is the second argument
|
|
if len(args) > 1 {
|
|
var err error
|
|
position, err = toInt(args[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
// Last argument is the position if it's a number
|
|
lastArg := args[len(args)-1]
|
|
if pos, err := toInt(lastArg); err == nil {
|
|
position = pos
|
|
values = args[:len(args)-1]
|
|
} else {
|
|
// All arguments are values to cycle through
|
|
values = args
|
|
// Position defaults to 0
|
|
}
|
|
}
|
|
|
|
// Handle empty values
|
|
if len(values) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Get the value at the specified position (with wrapping)
|
|
index := position % len(values)
|
|
if index < 0 {
|
|
index += len(values)
|
|
}
|
|
|
|
return values[index], nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionInclude(args ...interface{}) (interface{}, error) {
|
|
// This should be handled by the template engine, not directly as a function
|
|
return nil, errors.New("include function should be used as a tag: {% include 'template.twig' %}")
|
|
}
|
|
|
|
func (e *CoreExtension) functionJsonEncode(args ...interface{}) (interface{}, error) {
|
|
if len(args) == 0 {
|
|
return "null", nil
|
|
}
|
|
|
|
// Default options
|
|
options := 0
|
|
|
|
// Check for options flag
|
|
if len(args) > 1 {
|
|
if opt, err := toInt(args[1]); err == nil {
|
|
options = opt
|
|
}
|
|
}
|
|
|
|
// Convert the value to JSON
|
|
data, err := json.Marshal(args[0])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := string(data)
|
|
|
|
// Apply options (simplified)
|
|
// In real Twig, there are constants like JSON_PRETTY_PRINT, JSON_HEX_TAG, etc.
|
|
// Here we just do a simple pretty print if options is non-zero
|
|
if options != 0 {
|
|
var prettyJSON bytes.Buffer
|
|
if err := json.Indent(&prettyJSON, data, "", " "); err == nil {
|
|
result = prettyJSON.String()
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionLength(args ...interface{}) (interface{}, error) {
|
|
if len(args) != 1 {
|
|
return nil, errors.New("length function requires exactly one argument")
|
|
}
|
|
|
|
// Make sure nil is handled properly
|
|
if args[0] == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
result, err := length(args[0])
|
|
if err != nil {
|
|
// Return 0 for things that don't have a clear length
|
|
return 0, nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *CoreExtension) functionMerge(args ...interface{}) (interface{}, error) {
|
|
if len(args) < 2 {
|
|
return nil, errors.New("merge function requires at least two arguments to merge")
|
|
}
|
|
|
|
// Get the first argument as the base value
|
|
base := args[0]
|
|
|
|
// If it's an array or slice, merge with other arrays
|
|
rv := reflect.ValueOf(base)
|
|
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array {
|
|
// Start with a copy of the base slice
|
|
var result []interface{}
|
|
|
|
// Add base elements
|
|
baseRv := reflect.ValueOf(base)
|
|
for i := 0; i < baseRv.Len(); i++ {
|
|
result = append(result, baseRv.Index(i).Interface())
|
|
}
|
|
|
|
// Add elements from the other slices
|
|
for i := 1; i < len(args); i++ {
|
|
arg := args[i]
|
|
argRv := reflect.ValueOf(arg)
|
|
if argRv.Kind() == reflect.Slice || argRv.Kind() == reflect.Array {
|
|
for j := 0; j < argRv.Len(); j++ {
|
|
result = append(result, argRv.Index(j).Interface())
|
|
}
|
|
} else {
|
|
// If it's not a slice, just append it as a single item
|
|
result = append(result, arg)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// If it's a map, merge with other maps
|
|
if rv.Kind() == reflect.Map {
|
|
// Create a new map to store the merged result
|
|
result := make(map[string]interface{})
|
|
|
|
// Add all entries from the base map
|
|
if baseMap, ok := base.(map[string]interface{}); ok {
|
|
for k, v := range baseMap {
|
|
result[k] = v
|
|
}
|
|
} else {
|
|
// Use reflection for other map types
|
|
baseRv := reflect.ValueOf(base)
|
|
for _, key := range baseRv.MapKeys() {
|
|
keyStr := toString(key.Interface())
|
|
result[keyStr] = baseRv.MapIndex(key).Interface()
|
|
}
|
|
}
|
|
|
|
// Add entries from other maps
|
|
for i := 1; i < len(args); i++ {
|
|
arg := args[i]
|
|
if argMap, ok := arg.(map[string]interface{}); ok {
|
|
for k, v := range argMap {
|
|
result[k] = v
|
|
}
|
|
} else {
|
|
// Use reflection for other map types
|
|
argRv := reflect.ValueOf(arg)
|
|
if argRv.Kind() == reflect.Map {
|
|
for _, key := range argRv.MapKeys() {
|
|
keyStr := toString(key.Interface())
|
|
result[keyStr] = argRv.MapIndex(key).Interface()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot merge %T, expected array or map", base)
|
|
}
|
|
|
|
func escapeHTML(s string) string {
|
|
return html.EscapeString(s)
|
|
}
|
|
|
|
// filterFormat implements the format filter similar to fmt.Sprintf
|
|
func (e *CoreExtension) filterFormat(value interface{}, args ...interface{}) (interface{}, error) {
|
|
formatString := toString(value)
|
|
|
|
// If no args, just return the string
|
|
if len(args) == 0 {
|
|
return formatString, nil
|
|
}
|
|
|
|
// Apply formatting
|
|
return fmt.Sprintf(formatString, args...), nil
|
|
}
|
|
|
|
// filterJsonEncode implements a filter version of the json_encode function
|
|
func (e *CoreExtension) filterJsonEncode(value interface{}, args ...interface{}) (interface{}, error) {
|
|
// Default options
|
|
options := 0
|
|
|
|
// Check for options flag
|
|
if len(args) > 0 {
|
|
if opt, err := toInt(args[0]); err == nil {
|
|
options = opt
|
|
}
|
|
}
|
|
|
|
// Convert the value to JSON
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := string(data)
|
|
|
|
// Apply options (simplified)
|
|
// In real Twig, there are constants like JSON_PRETTY_PRINT, JSON_HEX_TAG, etc.
|
|
// Here we just do a simple pretty print if options is non-zero
|
|
if options != 0 {
|
|
var prettyJSON bytes.Buffer
|
|
if err := json.Indent(&prettyJSON, data, "", " "); err == nil {
|
|
result = prettyJSON.String()
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// filterSpaceless removes whitespace between HTML tags
|
|
func (e *CoreExtension) filterSpaceless(value interface{}, args ...interface{}) (interface{}, error) {
|
|
if value == nil {
|
|
return "", nil
|
|
}
|
|
|
|
// Convert to string if not already
|
|
str := fmt.Sprintf("%v", value)
|
|
if str == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Use regex to find whitespace between tags
|
|
// This will match one or more whitespace characters between a closing tag and an opening tag
|
|
re := regexp.MustCompile(`>\s+<`)
|
|
result := re.ReplaceAllString(str, "><")
|
|
|
|
return result, nil
|
|
}
|