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