mirror of
https://github.com/semihalev/twig.git
synced 2026-03-14 13:55:46 +01:00
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:
parent
8809c704bb
commit
b44bad903b
9 changed files with 387 additions and 15 deletions
76
apply_tag_test.go
Normal file
76
apply_tag_test.go
Normal 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), " ")
|
||||
}
|
||||
21
extension.go
21
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
|
||||
}
|
||||
|
|
|
|||
64
node.go
64
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
|
||||
|
|
|
|||
55
parse_apply.go
Normal file
55
parse_apply.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
59
spaceless_filter_test.go
Normal 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
87
spaceless_tag_test.go
Normal 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), " ")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue