diff --git a/render/context.go b/render/context.go index c12ed0ce..abd5f135 100644 --- a/render/context.go +++ b/render/context.go @@ -42,6 +42,10 @@ type Context interface { // RenderFile parses and renders a template. It's used in the implementation of the {% include %} tag. // RenderFile does not cache the compiled template. RenderFile(string, map[string]any) (string, error) + // RenderFileIsolated parses and renders a template with an isolated scope. + // Unlike RenderFile, it does not inherit variables from the parent context. + // It's used in the implementation of the {% render %} tag. + RenderFileIsolated(string, map[string]any) (string, error) // Set updates the value of a variable in the current lexical environment. // It's used in the implementation of the {% assign %} and {% capture %} tags. Set(name string, value any) @@ -193,6 +197,40 @@ func (c rendererContext) RenderFile(filename string, b map[string]any) (string, return buf.String(), nil } +// RenderFileIsolated renders a template with an isolated scope (no parent variables). +// This is used by the {% render %} tag to provide true variable isolation. +func (c rendererContext) RenderFileIsolated(filename string, b map[string]any) (string, error) { + source, err := c.ctx.config.TemplateStore.ReadTemplate(filename) + if err != nil && os.IsNotExist(err) { + // Is it cached? + if cval, ok := c.ctx.config.Cache[filename]; ok { + source = cval + } else { + return "", err + } + } else if err != nil { + return "", err + } + + root, err := c.ctx.config.Compile(string(source), c.node.SourceLoc) + if err != nil { + return "", err + } + + // Use only the provided bindings (isolated scope - no parent context) + bindings := map[string]any{} + for k, v := range b { + bindings[k] = v + } + + buf := new(bytes.Buffer) + if err := Render(root, buf, bindings, c.ctx.config); err != nil { + return "", err + } + + return buf.String(), nil +} + // InnerString renders the children to a string. func (c rendererContext) InnerString() (string, error) { buf := new(bytes.Buffer) diff --git a/tags/render_tag.go b/tags/render_tag.go new file mode 100644 index 00000000..55393aee --- /dev/null +++ b/tags/render_tag.go @@ -0,0 +1,440 @@ +package tags + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/osteele/liquid/expressions" + "github.com/osteele/liquid/render" +) + +// renderArgs represents the parsed arguments for a render tag +type renderArgs struct { + templateName expressions.Expression + params map[string]expressions.Expression + withValue expressions.Expression // for "with" syntax + withAlias string // for "with ... as alias" syntax + forValue expressions.Expression // for "for" syntax + forAlias string // for "for ... as alias" syntax +} + +// parseRenderArgs parses the arguments of a {% render %} tag +// Supports the following syntaxes: +// +// {% render 'template' %} +// {% render 'template', key: value, key2: value2 %} +// {% render 'template' with object %} +// {% render 'template' with object as name %} +// {% render 'template' for array %} +// {% render 'template' for array as item %} +// {% render 'template' for array as item, key: value %} +func parseRenderArgs(source string) (*renderArgs, error) { + args := &renderArgs{ + params: make(map[string]expressions.Expression), + } + + // Trim whitespace + source = strings.TrimSpace(source) + if source == "" { + return nil, fmt.Errorf("render tag requires a template name") + } + + // Parse template name (first argument) + // Find the end of the template name (could be a string or variable) + var templateNameStr string + var rest string + + // Check if it starts with a quote (string literal) + if strings.HasPrefix(source, "'") || strings.HasPrefix(source, "\"") { + quote := source[0] + endQuote := strings.IndexByte(source[1:], quote) + if endQuote == -1 { + return nil, fmt.Errorf("unclosed quote in template name") + } + templateNameStr = source[0 : endQuote+2] // include both quotes + rest = strings.TrimSpace(source[endQuote+2:]) + } else { + // Variable name (no quotes) + parts := strings.Fields(source) + if len(parts) == 0 { + return nil, fmt.Errorf("render tag requires a template name") + } + // Find where the template name ends (before comma, 'with', 'for', or end) + templateNameStr = parts[0] + // Remove the template name from source + rest = strings.TrimSpace(source[len(templateNameStr):]) + } + + // Parse the template name as an expression + templateExpr, err := expressions.Parse(templateNameStr) + if err != nil { + return nil, fmt.Errorf("invalid template name: %w", err) + } + args.templateName = templateExpr + + // Remove leading comma if present + rest = strings.TrimSpace(rest) + if strings.HasPrefix(rest, ",") { + rest = strings.TrimSpace(rest[1:]) + } + + // Parse the rest of the arguments + if rest == "" { + return args, nil + } + + // Check for 'with' or 'for' keywords + if strings.HasPrefix(rest, "with ") { + // Parse "with" syntax: with object [as alias] [, params] + rest = strings.TrimSpace(rest[5:]) // remove "with " + + // Find the end of the object expression (before 'as' or ',') + withEnd := len(rest) + asIndex := strings.Index(rest, " as ") + commaIndex := strings.IndexByte(rest, ',') + + if asIndex != -1 && (commaIndex == -1 || asIndex < commaIndex) { + withEnd = asIndex + } else if commaIndex != -1 { + withEnd = commaIndex + } + + withValueStr := strings.TrimSpace(rest[:withEnd]) + withExpr, err := expressions.Parse(withValueStr) + if err != nil { + return nil, fmt.Errorf("invalid 'with' value: %w", err) + } + args.withValue = withExpr + + rest = strings.TrimSpace(rest[withEnd:]) + + // Check for 'as alias' + if strings.HasPrefix(rest, "as ") { + rest = strings.TrimSpace(rest[3:]) // remove "as " + // Get the alias name (before comma or end) + aliasEnd := strings.IndexByte(rest, ',') + if aliasEnd == -1 { + args.withAlias = strings.TrimSpace(rest) + rest = "" + } else { + args.withAlias = strings.TrimSpace(rest[:aliasEnd]) + rest = strings.TrimSpace(rest[aliasEnd+1:]) + } + } + } else if strings.HasPrefix(rest, "for ") { + // Parse "for" syntax: for array [as item] [, params] + rest = strings.TrimSpace(rest[4:]) // remove "for " + + // Find the end of the array expression (before 'as' or ',') + forEnd := len(rest) + asIndex := strings.Index(rest, " as ") + commaIndex := strings.IndexByte(rest, ',') + + if asIndex != -1 && (commaIndex == -1 || asIndex < commaIndex) { + forEnd = asIndex + } else if commaIndex != -1 { + forEnd = commaIndex + } + + forValueStr := strings.TrimSpace(rest[:forEnd]) + forExpr, err := expressions.Parse(forValueStr) + if err != nil { + return nil, fmt.Errorf("invalid 'for' value: %w", err) + } + args.forValue = forExpr + + rest = strings.TrimSpace(rest[forEnd:]) + + // Check for 'as alias' + if strings.HasPrefix(rest, "as ") { + rest = strings.TrimSpace(rest[3:]) // remove "as " + // Get the alias name (before comma or end) + aliasEnd := strings.IndexByte(rest, ',') + if aliasEnd == -1 { + args.forAlias = strings.TrimSpace(rest) + rest = "" + } else { + args.forAlias = strings.TrimSpace(rest[:aliasEnd]) + rest = strings.TrimSpace(rest[aliasEnd+1:]) + } + } + } + + // Parse remaining parameters (key: value pairs) + if rest != "" { + // Remove leading comma if present + rest = strings.TrimSpace(rest) + if strings.HasPrefix(rest, ",") { + rest = strings.TrimSpace(rest[1:]) + } + + // Parse key-value pairs + if err := parseKeyValuePairs(rest, args.params); err != nil { + return nil, err + } + } + + return args, nil +} + +// parseKeyValuePairs parses comma-separated key: value pairs +func parseKeyValuePairs(source string, params map[string]expressions.Expression) error { + if source == "" { + return nil + } + + // Simple parser for key: value pairs + // This is a basic implementation - a more robust parser would handle nested structures + parts := splitPreservingQuotes(source, ',') + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Split on first colon + colonIndex := strings.IndexByte(part, ':') + if colonIndex == -1 { + return fmt.Errorf("invalid parameter format (expected 'key: value'): %s", part) + } + + key := strings.TrimSpace(part[:colonIndex]) + valueStr := strings.TrimSpace(part[colonIndex+1:]) + + // Validate key (must be a valid identifier) + if !isValidIdentifier(key) { + return fmt.Errorf("invalid parameter name: %s", key) + } + + // Parse value as expression + valueExpr, err := expressions.Parse(valueStr) + if err != nil { + return fmt.Errorf("invalid parameter value for '%s': %w", key, err) + } + + params[key] = valueExpr + } + + return nil +} + +// splitPreservingQuotes splits a string by delimiter, but preserves quoted strings +func splitPreservingQuotes(s string, delimiter byte) []string { + var result []string + var current strings.Builder + inQuote := false + var quoteChar byte + + for i := 0; i < len(s); i++ { + ch := s[i] + + if (ch == '"' || ch == '\'') && (i == 0 || s[i-1] != '\\') { + if !inQuote { + inQuote = true + quoteChar = ch + } else if ch == quoteChar { + inQuote = false + } + current.WriteByte(ch) + } else if ch == delimiter && !inQuote { + if current.Len() > 0 { + result = append(result, current.String()) + current.Reset() + } + } else { + current.WriteByte(ch) + } + } + + if current.Len() > 0 { + result = append(result, current.String()) + } + + return result +} + +// isValidIdentifier checks if a string is a valid identifier (alphanumeric + underscore) +func isValidIdentifier(s string) bool { + if s == "" { + return false + } + for i, ch := range s { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_' || (i > 0 && ch >= '0' && ch <= '9')) { + return false + } + } + return true +} + +// renderTag implements the {% render %} tag with isolated scope +// Syntax: +// +// {% render 'template' %} +// {% render 'template', key: value %} +// {% render 'template' with object %} +// {% render 'template' with object as name %} +// {% render 'template' for array %} +// {% render 'template' for array as item %} +func renderTag(source string) (func(io.Writer, render.Context) error, error) { + args, err := parseRenderArgs(source) + if err != nil { + return nil, err + } + + return func(w io.Writer, ctx render.Context) error { + // Evaluate template name + templateNameValue, err := ctx.Evaluate(args.templateName) + if err != nil { + return err + } + + templateName, ok := templateNameValue.(string) + if !ok { + return ctx.Errorf("render requires a string template name; got %T", templateNameValue) + } + + // Build the file path + filename := filepath.Join(filepath.Dir(ctx.SourceFile()), templateName) + + // Handle 'for' parameter (render for each item in array) + if args.forValue != nil { + return renderFor(w, ctx, filename, args) + } + + // Build isolated scope with parameters + isolatedScope, err := buildIsolatedScope(ctx, args) + if err != nil { + return err + } + + // Render with isolated scope + s, err := renderFileIsolated(ctx, filename, isolatedScope) + if err != nil { + return err + } + + _, err = io.WriteString(w, s) + return err + }, nil +} + +// buildIsolatedScope creates an isolated scope with only the passed parameters +func buildIsolatedScope(ctx render.Context, args *renderArgs) (map[string]any, error) { + scope := make(map[string]any) + + // Add 'with' parameter if present + if args.withValue != nil { + value, err := ctx.Evaluate(args.withValue) + if err != nil { + return nil, err + } + + // Use alias if provided, otherwise use template filename as key + if args.withAlias != "" { + scope[args.withAlias] = value + } else { + // Default behavior: make the object available by template name + // This is simplified - Shopify uses the template filename + scope["object"] = value + } + } + + // Add explicit parameters + for key, valueExpr := range args.params { + value, err := ctx.Evaluate(valueExpr) + if err != nil { + return nil, fmt.Errorf("error evaluating parameter '%s': %w", key, err) + } + scope[key] = value + } + + return scope, nil +} + +// renderFor renders the template for each item in an array +func renderFor(w io.Writer, ctx render.Context, filename string, args *renderArgs) error { + // Evaluate the array + arrayValue, err := ctx.Evaluate(args.forValue) + if err != nil { + return err + } + + // Convert to slice + items, ok := convertToSlice(arrayValue) + if !ok { + return ctx.Errorf("'for' parameter must be an array; got %T", arrayValue) + } + + // Determine the variable name for each item + itemName := args.forAlias + if itemName == "" { + // Default: use template filename without extension as variable name + // This is simplified - Shopify's behavior is more complex + itemName = "item" + } + + // Render for each item + for i, item := range items { + // Build scope with forloop object + scope := make(map[string]any) + + // Add the current item + scope[itemName] = item + + // Add forloop object (Shopify-compatible) + scope["forloop"] = map[string]any{ + "first": i == 0, + "last": i == len(items)-1, + "index": i + 1, // 1-indexed + "index0": i, // 0-indexed + "length": len(items), + "rindex": len(items) - i, // reverse index (1-indexed) + "rindex0": len(items) - i - 1, // reverse index (0-indexed) + } + + // Add explicit parameters + for key, valueExpr := range args.params { + value, err := ctx.Evaluate(valueExpr) + if err != nil { + return fmt.Errorf("error evaluating parameter '%s': %w", key, err) + } + scope[key] = value + } + + // Render with isolated scope + s, err := renderFileIsolated(ctx, filename, scope) + if err != nil { + return err + } + + if _, err := io.WriteString(w, s); err != nil { + return err + } + } + + return nil +} + +// convertToSlice attempts to convert a value to []any +func convertToSlice(v any) ([]any, bool) { + if v == nil { + return nil, false + } + + switch arr := v.(type) { + case []any: + return arr, true + default: + // Try reflection for other slice types + return nil, false + } +} + +// renderFileIsolated renders a file with an isolated scope (no parent variables) +// Uses the RenderFileIsolated method which provides true variable isolation +func renderFileIsolated(ctx render.Context, filename string, isolatedScope map[string]any) (string, error) { + return ctx.RenderFileIsolated(filename, isolatedScope) +} diff --git a/tags/render_tag_test.go b/tags/render_tag_test.go new file mode 100644 index 00000000..4ebf0f70 --- /dev/null +++ b/tags/render_tag_test.go @@ -0,0 +1,397 @@ +package tags + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "github.com/osteele/liquid/parser" + "github.com/osteele/liquid/render" + "github.com/stretchr/testify/require" +) + +var renderTestBindings = map[string]any{ + "secret": "hidden-value", + "visible": "visible-value", + "product": map[string]any{ + "name": "Widget", + "price": 9.99, + }, + "products": []any{ + map[string]any{"name": "Item1"}, + map[string]any{"name": "Item2"}, + map[string]any{"name": "Item3"}, + }, +} + +// TestRenderTag_Basic tests basic render functionality +func TestRenderTag_Basic(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + root, err := config.Compile(`{% render "render_basic.html" %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + require.Equal(t, "Hello from render!", strings.TrimSpace(buf.String())) +} + +// TestRenderTag_WithParameters tests render with explicit parameters +func TestRenderTag_WithParameters(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Test with literal and variable values + root, err := config.Compile(`{% render "render_with_params.html", title: "Widget", price: 9.99 %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + require.Equal(t, "Title: Widget, Price: 9.99", strings.TrimSpace(buf.String())) +} + +// TestRenderTag_IsolatedScope tests that parent variables are not accessible +func TestRenderTag_IsolatedScope(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Render should NOT have access to 'secret' from parent, but should have 'visible' passed as parameter + root, err := config.Compile(`{% render "render_isolated.html", visible: visible %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + + // 'secret' should be empty (not accessible), 'visible' should be present + result := strings.TrimSpace(buf.String()) + require.Equal(t, "Secret: , Visible: visible-value", result) +} + +// TestRenderTag_WithObject tests "render with object" syntax +func TestRenderTag_WithObject(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Test: {% render "template" with object as item %} + root, err := config.Compile(`{% render "render_with_object.html" with product as item %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + require.Equal(t, "Product: Widget - $9.99", strings.TrimSpace(buf.String())) +} + +// TestRenderTag_ForLoop tests "render for array" syntax +func TestRenderTag_ForLoop(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Test: {% render "template" for array as item %} + root, err := config.Compile(`{% render "render_for_loop.html" for products as item %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + + // Should render once for each item with forloop object + result := strings.TrimSpace(buf.String()) + require.Contains(t, result, "Item1 (1/3)") + require.Contains(t, result, "Item2 (2/3)") + require.Contains(t, result, "Item3 (3/3)") +} + +// TestRenderTag_ForLoopWithParams tests combining "for" with explicit parameters +func TestRenderTag_ForLoopWithParams(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Create a template that uses both the loop item and a parameter + config.Cache["testdata/render_combined.html"] = []byte(`{{ item.name }} - {{ label }}`) + + root, err := config.Compile(`{% render "render_combined.html" for products as item, label: "Product" %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + + result := strings.TrimSpace(buf.String()) + require.Contains(t, result, "Item1 - Product") + require.Contains(t, result, "Item2 - Product") + require.Contains(t, result, "Item3 - Product") +} + +// TestRenderTag_DynamicTemplateName tests variable template names +func TestRenderTag_DynamicTemplateName(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + bindings := map[string]any{ + "template_name": "render_basic.html", + } + + root, err := config.Compile(`{% render template_name %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, bindings, config) + require.NoError(t, err) + require.Equal(t, "Hello from render!", strings.TrimSpace(buf.String())) +} + +// TestRenderTag_FileNotFound tests error handling for missing files +func TestRenderTag_FileNotFound(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + root, err := config.Compile(`{% render "missing_file.html" %}`, loc) + require.NoError(t, err) + + err = render.Render(root, io.Discard, renderTestBindings, config) + require.Error(t, err) + require.True(t, os.IsNotExist(err.Cause())) +} + +// TestRenderTag_InvalidTemplateName tests error handling for non-string template names +func TestRenderTag_InvalidTemplateName(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + root, err := config.Compile(`{% render 123 %}`, loc) + require.NoError(t, err) + + err = render.Render(root, io.Discard, renderTestBindings, config) + require.Error(t, err) + require.Contains(t, err.Error(), "string template name") +} + +// TestRenderTag_InvalidSyntax tests various syntax errors +func TestRenderTag_InvalidSyntax(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + tests := []struct { + name string + template string + }{ + {"missing template name", `{% render %}`}, + {"invalid parameter format", `{% render "test", invalid %}`}, + {"unclosed quote", `{% render "test %}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := config.Compile(tt.template, loc) + // Some errors might be caught during compilation, others during rendering + if err == nil { + root, _ := config.Compile(tt.template, loc) + err = render.Render(root, io.Discard, renderTestBindings, config) + } + require.Error(t, err, "expected error for: %s", tt.template) + }) + } +} + +// TestRenderTag_WithExpressionInParameters tests using expressions/filters in parameters +func TestRenderTag_WithExpressionInParameters(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + // Need to add standard filters for size and times to work + config.AddFilter("size", func(v any) int { + if arr, ok := v.([]any); ok { + return len(arr) + } + return 0 + }) + config.AddFilter("times", func(a, b int) int { + return a * b + }) + + bindings := map[string]any{ + "items": []any{"a", "b", "c"}, + "price": 10, + } + + // Template that uses the evaluated parameters + config.Cache["testdata/render_expr.html"] = []byte(`Count: {{ count }}, Total: {{ total }}`) + + // Use filters in parameter values + root, err := config.Compile(`{% render "render_expr.html", count: items | size, total: price | times: 2 %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, bindings, config) + require.NoError(t, err) + require.Equal(t, "Count: 3, Total: 20", strings.TrimSpace(buf.String())) +} + +// TestRenderTag_ForloopVariables tests all forloop object properties +func TestRenderTag_ForloopVariables(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Add a newline at the end of each iteration + config.Cache["testdata/render_forloop.html"] = []byte( + "{{ forloop.index }},{{ forloop.index0 }},{{ forloop.rindex }},{{ forloop.rindex0 }},{{ forloop.first }},{{ forloop.last }},{{ forloop.length }}\n", + ) + + bindings := map[string]any{ + "items": []any{"a", "b", "c"}, + } + + root, err := config.Compile(`{% render "render_forloop.html" for items as item %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, bindings, config) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + require.Len(t, lines, 3) + + // First iteration: index=1, index0=0, rindex=3, rindex0=2, first=true, last=false, length=3 + require.Contains(t, lines[0], "1,0,3,2,true,false,3") + // Second iteration: index=2, index0=1, rindex=2, rindex0=1, first=false, last=false, length=3 + require.Contains(t, lines[1], "2,1,2,1,false,false,3") + // Third iteration: index=3, index0=2, rindex=1, rindex0=0, first=false, last=true, length=3 + require.Contains(t, lines[2], "3,2,1,0,false,true,3") +} + +// TestRenderTag_CachedTemplate tests rendering from cached templates +func TestRenderTag_CachedTemplate(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: "testdata/render_source.html", LineNo: 1} + + AddStandardTags(&config) + + // Add a template to the cache that doesn't exist as a file + config.Cache["testdata/cached_template.html"] = []byte("Cached: {{ value }}") + + root, err := config.Compile(`{% render "cached_template.html", value: "test" %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, nil, config) + require.NoError(t, err) + require.Equal(t, "Cached: test", strings.TrimSpace(buf.String())) +} + +// TestParseRenderArgs tests the argument parsing function +func TestParseRenderArgs(t *testing.T) { + tests := []struct { + name string + input string + shouldError bool + checkFunc func(*testing.T, *renderArgs) + }{ + { + name: "basic template name", + input: `"template.html"`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.NotNil(t, args.templateName) + require.Empty(t, args.params) + require.Nil(t, args.withValue) + require.Nil(t, args.forValue) + }, + }, + { + name: "template with single parameter", + input: `"template.html", key: value`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.NotNil(t, args.templateName) + require.Len(t, args.params, 1) + require.Contains(t, args.params, "key") + }, + }, + { + name: "template with multiple parameters", + input: `"template.html", key1: value1, key2: value2`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.Len(t, args.params, 2) + require.Contains(t, args.params, "key1") + require.Contains(t, args.params, "key2") + }, + }, + { + name: "with syntax", + input: `"template.html" with object as item`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.NotNil(t, args.withValue) + require.Equal(t, "item", args.withAlias) + }, + }, + { + name: "for syntax", + input: `"template.html" for items as item`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.NotNil(t, args.forValue) + require.Equal(t, "item", args.forAlias) + }, + }, + { + name: "for syntax with parameters", + input: `"template.html" for items as item, key: value`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.NotNil(t, args.forValue) + require.Equal(t, "item", args.forAlias) + require.Len(t, args.params, 1) + }, + }, + { + name: "empty input", + input: ``, + shouldError: true, + }, + { + name: "invalid parameter format", + input: `"template.html", invalid`, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, err := parseRenderArgs(tt.input) + if tt.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + if tt.checkFunc != nil { + tt.checkFunc(t, args) + } + } + }) + } +} diff --git a/tags/standard_tags.go b/tags/standard_tags.go index b48fb2f4..8a861ad2 100644 --- a/tags/standard_tags.go +++ b/tags/standard_tags.go @@ -13,6 +13,7 @@ import ( func AddStandardTags(c *render.Config) { c.AddTag("assign", makeAssignTag(c)) c.AddTag("include", includeTag) + c.AddTag("render", renderTag) // blocks // The parser only recognize the comment and raw tags if they've been defined, diff --git a/tags/testdata/render_basic.html b/tags/testdata/render_basic.html new file mode 100644 index 00000000..079f1928 --- /dev/null +++ b/tags/testdata/render_basic.html @@ -0,0 +1 @@ +Hello from render! \ No newline at end of file diff --git a/tags/testdata/render_for_loop.html b/tags/testdata/render_for_loop.html new file mode 100644 index 00000000..b47f88c1 --- /dev/null +++ b/tags/testdata/render_for_loop.html @@ -0,0 +1 @@ +{{ item.name }} ({{ forloop.index }}/{{ forloop.length }}) \ No newline at end of file diff --git a/tags/testdata/render_isolated.html b/tags/testdata/render_isolated.html new file mode 100644 index 00000000..3d74b310 --- /dev/null +++ b/tags/testdata/render_isolated.html @@ -0,0 +1 @@ +Secret: {{ secret }}, Visible: {{ visible }} \ No newline at end of file diff --git a/tags/testdata/render_with_object.html b/tags/testdata/render_with_object.html new file mode 100644 index 00000000..0e8857ee --- /dev/null +++ b/tags/testdata/render_with_object.html @@ -0,0 +1 @@ +Product: {{ item.name }} - ${{ item.price }} \ No newline at end of file diff --git a/tags/testdata/render_with_params.html b/tags/testdata/render_with_params.html new file mode 100644 index 00000000..51573cc3 --- /dev/null +++ b/tags/testdata/render_with_params.html @@ -0,0 +1 @@ +Title: {{ title }}, Price: {{ price }} \ No newline at end of file