Implement spaceless filter and apply tag

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>
This commit is contained in:
semihalev 2025-03-11 17:28:57 +03:00
commit b44bad903b
9 changed files with 387 additions and 15 deletions

76
apply_tag_test.go Normal file
View file

@ -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 %}
<div>
<strong>foo</strong>
</div>
{% 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 := "<div><strong>foo</strong></div>"
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), " ")
}

View file

@ -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
}

64
node.go
View file

@ -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

55
parse_apply.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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
}

59
spaceless_filter_test.go Normal file
View file

@ -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: `{{ "<div> <strong>foo</strong> </div>" | spaceless }}`,
context: nil,
expected: "<div><strong>foo</strong></div>",
},
{
name: "HTML with newlines and spaces",
template: `{{ "<div>\n <p> Hello </p>\n <p> World </p>\n</div>" | spaceless }}`,
context: nil,
expected: "<div><p> Hello </p><p> World </p></div>",
},
{
name: "Variable with spaceless filter",
template: `{{ html | spaceless }}`,
context: map[string]interface{}{
"html": "<div>\n <strong>foo</strong>\n</div>",
},
expected: "<div><strong>foo</strong></div>",
},
{
name: "Chain filters ending with spaceless",
template: `{{ "<div>\n <p>hello</p>\n</div>" | upper | spaceless }}`,
context: nil,
expected: "<DIV><P>HELLO</P></DIV>",
},
}
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)
}
})
}
}

87
spaceless_tag_test.go Normal file
View file

@ -0,0 +1,87 @@
package twig
import (
"strings"
"testing"
)
func TestSpacelessTag(t *testing.T) {
engine := New()
// Test simple case with spaceless tag
template1 := `{% spaceless %}
<div>
<strong>foo</strong>
</div>
{% 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 := "<div><strong>foo</strong></div>"
if normalizeSpacelessOutput(output) != normalizeSpacelessOutput(expected) {
t.Errorf("Test 1 failed. Expected '%s', got '%s'", expected, output)
}
// Test with nested tags
template2 := `{% spaceless %}
<div>
<p>
<span>Hello</span>
<span>World</span>
</p>
</div>
{% 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 = "<div><p><span>Hello</span><span>World</span></p></div>"
if normalizeSpacelessOutput(output) != normalizeSpacelessOutput(expected) {
t.Errorf("Test 2 failed. Expected '%s', got '%s'", expected, output)
}
// Test with variable content
template3 := `{% spaceless %}
<div>
{{ content }}
</div>
{% endspaceless %}`
result, err = engine.ParseTemplate(template3)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
context := map[string]interface{}{
"content": "<strong>Important</strong>",
}
output, err = result.Render(context)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
expected = "<div><strong>Important</strong></div>"
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), " ")
}

View file

@ -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