From b44bad903b87527d2b7b3863f3d98774e36ea42a Mon Sep 17 00:00:00 2001 From: semihalev Date: Tue, 11 Mar 2025 17:28:57 +0300 Subject: [PATCH] Implement spaceless filter and apply tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apply_tag_test.go | 76 +++++++++++++++++++++++++++++++++++ extension.go | 21 ++++++++++ node.go | 64 +++++++++++++++++++++++++++++ parse_apply.go | 55 +++++++++++++++++++++++++ parser.go | 4 +- relative_path_test.go | 14 ++----- spaceless_filter_test.go | 59 +++++++++++++++++++++++++++ spaceless_tag_test.go | 87 ++++++++++++++++++++++++++++++++++++++++ whitespace.go | 22 ++++++++-- 9 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 apply_tag_test.go create mode 100644 parse_apply.go create mode 100644 spaceless_filter_test.go create mode 100644 spaceless_tag_test.go 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: `{{ "
\n

Hello

\n

World

\n
" | spaceless }}`, + context: nil, + expected: "

Hello

World

", + }, + { + 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: `{{ "
\n

hello

\n
" | upper | spaceless }}`, + context: nil, + expected: "

HELLO

", + }, + } + + 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 %} +
+

+ Hello + World +

+
+{% 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 = "

HelloWorld

" + 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