diff --git a/apply_tag_test.go b/apply_tag_test.go
new file mode 100644
index 0000000..c67db6b
--- /dev/null
+++ b/apply_tag_test.go
@@ -0,0 +1,76 @@
+package twig
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestApplyTag(t *testing.T) {
+ engine := New()
+
+ // Test simple spaceless filter with apply tag
+ template1 := `{% apply spaceless %}
+
+ foo
+
+{% endapply %}`
+
+ result, err := engine.ParseTemplate(template1)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ output, err := result.Render(nil)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected := "foo
"
+ if normalizeOutput(output) != normalizeOutput(expected) {
+ t.Errorf("Test 1 failed. Expected '%s', got '%s'", expected, output)
+ }
+
+ // Test upper filter with apply tag
+ template2 := `{% apply upper %}hello world{% endapply %}`
+
+ result, err = engine.ParseTemplate(template2)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ output, err = result.Render(nil)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected = "HELLO WORLD"
+ if normalizeOutput(output) != normalizeOutput(expected) {
+ t.Errorf("Test 2 failed. Expected '%s', got '%s'", expected, output)
+ }
+
+ // Test with context variable
+ template3 := `{% apply upper %}{{ name }}{% endapply %}`
+
+ result, err = engine.ParseTemplate(template3)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ context := map[string]interface{}{
+ "name": "john",
+ }
+
+ output, err = result.Render(context)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected = "JOHN"
+ if normalizeOutput(output) != normalizeOutput(expected) {
+ t.Errorf("Test 3 failed. Expected '%s', got '%s'", expected, output)
+ }
+}
+
+func normalizeOutput(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+}
diff --git a/extension.go b/extension.go
index d4176d9..e022efe 100644
--- a/extension.go
+++ b/extension.go
@@ -103,6 +103,7 @@ func (e *CoreExtension) GetFilters() map[string]FilterFunc {
"nl2br": e.filterNl2Br,
"format": e.filterFormat,
"json_encode": e.filterJsonEncode,
+ "spaceless": e.filterSpaceless,
}
}
@@ -2259,3 +2260,23 @@ func (e *CoreExtension) filterJsonEncode(value interface{}, args ...interface{})
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
+}
diff --git a/node.go b/node.go
index 606182a..36ee929 100644
--- a/node.go
+++ b/node.go
@@ -146,6 +146,7 @@ const (
NodeSpaceless
NodeDo
NodeModuleMethod
+ NodeApply
)
// RootNode represents the root of a template
@@ -1304,6 +1305,69 @@ func (n *ElementNode) Line() int {
// SpacelessNode is implemented in whitespace.go
+// ApplyNode represents a {% apply filter %} ... {% endapply %} block
+type ApplyNode struct {
+ body []Node
+ filter string
+ args []Node
+ line int
+}
+
+// NewApplyNode creates a new apply node
+func NewApplyNode(body []Node, filter string, args []Node, line int) *ApplyNode {
+ return &ApplyNode{
+ body: body,
+ filter: filter,
+ args: args,
+ line: line,
+ }
+}
+
+func (n *ApplyNode) Type() NodeType {
+ return NodeApply
+}
+
+func (n *ApplyNode) Line() int {
+ return n.line
+}
+
+// Render renders the apply node by applying a filter to the rendered body
+func (n *ApplyNode) Render(w io.Writer, ctx *RenderContext) error {
+ // First render body content to a buffer
+ var buf bytes.Buffer
+
+ // Render all body nodes
+ for _, node := range n.body {
+ err := node.Render(&buf, ctx)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Get the body content
+ content := buf.String()
+
+ // Evaluate filter arguments
+ filterArgs := make([]interface{}, len(n.args))
+ for i, arg := range n.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return err
+ }
+ filterArgs[i] = val
+ }
+
+ // Apply the filter to the content
+ result, err := ctx.ApplyFilter(n.filter, content, filterArgs...)
+ if err != nil {
+ return err
+ }
+
+ // Write the filtered result
+ _, err = WriteString(w, ctx.ToString(result))
+ return err
+}
+
// Implement Node interface for RootNode
func (n *RootNode) Render(w io.Writer, ctx *RenderContext) error {
// First pass: collect blocks and check for extends
diff --git a/parse_apply.go b/parse_apply.go
new file mode 100644
index 0000000..2a33b29
--- /dev/null
+++ b/parse_apply.go
@@ -0,0 +1,55 @@
+package twig
+
+import (
+ "fmt"
+)
+
+func (p *Parser) parseApply(parser *Parser) (Node, error) {
+ // Get the line number of the apply token
+ applyLine := parser.tokens[parser.tokenIndex-2].Line
+
+ // Parse the filter name
+ if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_NAME {
+ return nil, fmt.Errorf("expected filter name after apply tag at line %d", applyLine)
+ }
+
+ filterName := parser.tokens[parser.tokenIndex].Value
+ parser.tokenIndex++
+
+ // Expect the block end token
+ if parser.tokenIndex >= len(parser.tokens) ||
+ (parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END &&
+ parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END_TRIM) {
+ return nil, fmt.Errorf("expected block end token after apply filter at line %d", applyLine)
+ }
+ parser.tokenIndex++
+
+ // Parse the apply body
+ applyBody, err := parser.parseOuterTemplate()
+ if err != nil {
+ return nil, err
+ }
+
+ // Expect endapply tag
+ if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_START {
+ return nil, fmt.Errorf("expected endapply tag at line %d", applyLine)
+ }
+ parser.tokenIndex++
+
+ // Expect 'endapply' token
+ if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_NAME || parser.tokens[parser.tokenIndex].Value != "endapply" {
+ return nil, fmt.Errorf("expected 'endapply' at line %d", applyLine)
+ }
+ parser.tokenIndex++
+
+ // Expect block end token
+ if parser.tokenIndex >= len(parser.tokens) ||
+ (parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END &&
+ parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END_TRIM) {
+ return nil, fmt.Errorf("expected block end token after endapply at line %d", applyLine)
+ }
+ parser.tokenIndex++
+
+ // Create apply node (no arguments for now)
+ return NewApplyNode(applyBody, filterName, nil, applyLine), nil
+}
diff --git a/parser.go b/parser.go
index 35ae65c..9e896b7 100644
--- a/parser.go
+++ b/parser.go
@@ -102,6 +102,7 @@ func (p *Parser) initBlockHandlers() {
"from": p.parseFrom,
"spaceless": p.parseSpaceless,
"verbatim": p.parseVerbatim,
+ "apply": p.parseApply,
// Special closing tags - they will be handled in their corresponding open tag parsers
"endif": p.parseEndTag,
@@ -109,6 +110,7 @@ func (p *Parser) initBlockHandlers() {
"endmacro": p.parseEndTag,
"endblock": p.parseEndTag,
"endspaceless": p.parseEndTag,
+ "endapply": p.parseEndTag,
"else": p.parseEndTag,
"elseif": p.parseEndTag,
"endverbatim": p.parseEndTag,
@@ -210,7 +212,7 @@ func (p *Parser) parseOuterTemplate() ([]Node, error) {
// Check if this is a control ending tag (endif, endfor, endblock, etc.)
if blockName == "endif" || blockName == "endfor" || blockName == "endblock" ||
blockName == "endmacro" || blockName == "else" || blockName == "elseif" ||
- blockName == "endspaceless" {
+ blockName == "endspaceless" || blockName == "endapply" || blockName == "endverbatim" {
// We should return to the parent parser that's handling the parent block
// First move back two steps to the start of the block tag
p.tokenIndex -= 2
diff --git a/relative_path_test.go b/relative_path_test.go
index 66d0167..0ddb9b1 100644
--- a/relative_path_test.go
+++ b/relative_path_test.go
@@ -119,15 +119,7 @@ func TestRelativePathsWithFromImport(t *testing.T) {
}
func normalizeWhitespace(s string) string {
- // This is a more robust implementation for whitespace normalization
- // Replace newlines with a single space
- s = strings.ReplaceAll(s, "\n", " ")
-
- // Replace multiple spaces with a single space
- for strings.Contains(s, " ") {
- s = strings.ReplaceAll(s, " ", " ")
- }
-
- // Trim leading and trailing spaces
- return strings.TrimSpace(s)
+ // Replace multiple whitespace characters with a single space
+ result := strings.Join(strings.Fields(s), " ")
+ return result
}
diff --git a/spaceless_filter_test.go b/spaceless_filter_test.go
new file mode 100644
index 0000000..1407ee0
--- /dev/null
+++ b/spaceless_filter_test.go
@@ -0,0 +1,59 @@
+package twig
+
+import "testing"
+
+func TestSpacelessFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ template string
+ context map[string]interface{}
+ expected string
+ }{
+ {
+ name: "Simple text with spaceless filter",
+ template: `{{ " foo
" | spaceless }}`,
+ context: nil,
+ expected: "foo
",
+ },
+ {
+ name: "HTML with newlines and spaces",
+ template: `{{ "" | spaceless }}`,
+ context: nil,
+ expected: "",
+ },
+ {
+ name: "Variable with spaceless filter",
+ template: `{{ html | spaceless }}`,
+ context: map[string]interface{}{
+ "html": "\n foo\n
",
+ },
+ expected: "foo
",
+ },
+ {
+ name: "Chain filters ending with spaceless",
+ template: `{{ "" | upper | spaceless }}`,
+ context: nil,
+ expected: "",
+ },
+ }
+
+ engine := New()
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ result, err := engine.ParseTemplate(test.template)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ output, err := result.Render(test.context)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ if output != test.expected {
+ t.Errorf("Template rendered incorrectly. Expected '%s', got '%s'", test.expected, output)
+ }
+ })
+ }
+}
diff --git a/spaceless_tag_test.go b/spaceless_tag_test.go
new file mode 100644
index 0000000..6df4874
--- /dev/null
+++ b/spaceless_tag_test.go
@@ -0,0 +1,87 @@
+package twig
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSpacelessTag(t *testing.T) {
+ engine := New()
+
+ // Test simple case with spaceless tag
+ template1 := `{% spaceless %}
+
+ foo
+
+{% endspaceless %}`
+
+ result, err := engine.ParseTemplate(template1)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ output, err := result.Render(nil)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected := "foo
"
+ if normalizeSpacelessOutput(output) != normalizeSpacelessOutput(expected) {
+ t.Errorf("Test 1 failed. Expected '%s', got '%s'", expected, output)
+ }
+
+ // Test with nested tags
+ template2 := `{% spaceless %}
+
+{% endspaceless %}`
+
+ result, err = engine.ParseTemplate(template2)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ output, err = result.Render(nil)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected = ""
+ if normalizeSpacelessOutput(output) != normalizeSpacelessOutput(expected) {
+ t.Errorf("Test 2 failed. Expected '%s', got '%s'", expected, output)
+ }
+
+ // Test with variable content
+ template3 := `{% spaceless %}
+
+ {{ content }}
+
+{% endspaceless %}`
+
+ result, err = engine.ParseTemplate(template3)
+ if err != nil {
+ t.Fatalf("Failed to parse template: %v", err)
+ }
+
+ context := map[string]interface{}{
+ "content": "Important",
+ }
+
+ output, err = result.Render(context)
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+
+ expected = "Important
"
+ if normalizeSpacelessOutput(output) != normalizeSpacelessOutput(expected) {
+ t.Errorf("Test 3 failed. Expected '%s', got '%s'", expected, output)
+ }
+}
+
+func normalizeSpacelessOutput(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+}
diff --git a/whitespace.go b/whitespace.go
index f68b83c..28e5e19 100644
--- a/whitespace.go
+++ b/whitespace.go
@@ -1,6 +1,7 @@
package twig
import (
+ "bytes"
"io"
"strings"
)
@@ -33,13 +34,28 @@ func NewSpacelessNode(body []Node, line int) *SpacelessNode {
// Render renders the node to a writer
func (n *SpacelessNode) Render(w io.Writer, ctx *RenderContext) error {
- // Just render the content directly - we don't manipulate HTML
+ // First render body content to a buffer
+ var buf bytes.Buffer
+
+ // Render all body nodes
for _, node := range n.body {
- if err := node.Render(w, ctx); err != nil {
+ err := node.Render(&buf, ctx)
+ if err != nil {
return err
}
}
- return nil
+
+ // Apply spaceless filter to the rendered content
+ result, err := ctx.ApplyFilter("spaceless", buf.String())
+ if err != nil {
+ // Fall back to original content on filter error
+ _, err = w.Write(buf.Bytes())
+ return err
+ }
+
+ // Write the processed result
+ _, err = WriteString(w, ctx.ToString(result))
+ return err
}
// Line returns the line number of the node