diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f90bf2d..542ed849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **Named Filter Arguments** (#42): Added keyword arguments for filters, including `allow_false` support in the `default` filter. - **String Literal Escapes** (#45): Added `\\`, `\"`, `\n`, `\t`, and `\r` escapes in double-quoted string literals. Single-quoted strings remain literal. - **Shopify Filters** (#146): Added `where`, `sum`, `at_least`, `at_most`, `pluralize`, `handleize`/`handle`, `remove_last`, and `replace_last`. +- **Render Tag** (#128, #129): Added isolated snippet rendering with named parameters and `with`, `for`, and `as` forms. ### Fixed diff --git a/render/context.go b/render/context.go index c35bdb59..ef336c81 100644 --- a/render/context.go +++ b/render/context.go @@ -160,6 +160,21 @@ func (c rendererContext) RenderChildren(w io.Writer) Error { } func (c rendererContext) RenderFile(filename string, b map[string]any) (string, error) { + bindings := make(map[string]any, len(c.ctx.bindings)+len(b)) + maps.Copy(bindings, c.ctx.bindings) + maps.Copy(bindings, b) + + return c.renderFile(filename, bindings) +} + +// RenderFileIsolated renders a template without inheriting the parent lexical scope. +// It is intentionally not part of Context, so adding the render tag does not break +// third-party Context implementations. +func (c rendererContext) RenderFileIsolated(filename string, bindings map[string]any) (string, error) { + return c.renderFile(filename, maps.Clone(bindings)) +} + +func (c rendererContext) renderFile(filename string, bindings map[string]any) (string, error) { source, err := c.ctx.config.TemplateStore.ReadTemplate(filename) if err != nil && errors.Is(err, fs.ErrNotExist) { // Is it cached? @@ -177,10 +192,6 @@ func (c rendererContext) RenderFile(filename string, b map[string]any) (string, return "", err } - bindings := make(map[string]any, len(c.ctx.bindings)+len(b)) - maps.Copy(bindings, c.ctx.bindings) - maps.Copy(bindings, b) - buf := new(bytes.Buffer) if err := Render(root, buf, bindings, c.ctx.config); err != nil { return "", err diff --git a/tags/render_tag.go b/tags/render_tag.go new file mode 100644 index 00000000..81ef3360 --- /dev/null +++ b/tags/render_tag.go @@ -0,0 +1,476 @@ +package tags + +import ( + "fmt" + "io" + "maps" + "path" + "path/filepath" + "reflect" + "strings" + "unicode" + "unicode/utf8" + + "github.com/osteele/liquid/expressions" + "github.com/osteele/liquid/render" +) + +type renderParam struct { + name string + value expressions.Expression +} + +type renderArgs struct { + templateName expressions.Expression + params []renderParam + withValue expressions.Expression + withAlias string + forValue expressions.Expression + forAlias string +} + +type isolatedFileRenderer interface { + RenderFileIsolated(string, map[string]any) (string, error) +} + +// parseRenderArgs parses the arguments of a render tag. +func parseRenderArgs(source string) (*renderArgs, error) { + source = strings.TrimSpace(source) + if source == "" { + return nil, fmt.Errorf("render tag requires a template name") + } + + templateSource, rest, err := takeRenderTemplateName(source) + if err != nil { + return nil, err + } + + templateExpr, err := expressions.Parse(templateSource) + if err != nil { + return nil, fmt.Errorf("invalid template name: %w", err) + } + + args := &renderArgs{templateName: templateExpr} + rest = trimOptionalComma(rest) + if rest == "" { + return args, nil + } + + var valueSource, alias, paramSource string + if modifierSource, ok := consumeRenderKeyword(rest, "with"); ok { + valueSource, alias, paramSource, err = parseRenderModifier(modifierSource) + if err != nil { + return nil, fmt.Errorf("invalid 'with' argument: %w", err) + } + args.withValue, err = expressions.Parse(valueSource) + if err != nil { + return nil, fmt.Errorf("invalid 'with' value: %w", err) + } + args.withAlias = alias + rest = paramSource + } else if modifierSource, ok := consumeRenderKeyword(rest, "for"); ok { + valueSource, alias, paramSource, err = parseRenderModifier(modifierSource) + if err != nil { + return nil, fmt.Errorf("invalid 'for' argument: %w", err) + } + args.forValue, err = expressions.Parse(valueSource) + if err != nil { + return nil, fmt.Errorf("invalid 'for' value: %w", err) + } + args.forAlias = alias + rest = paramSource + } + + args.params, err = parseRenderParams(rest) + if err != nil { + return nil, err + } + + return args, nil +} + +func takeRenderTemplateName(source string) (string, string, error) { + if source[0] == '\'' || source[0] == '"' { + end := findClosingQuote(source, 0) + if end < 0 { + return "", "", fmt.Errorf("unclosed quote in template name") + } + + return source[:end+1], strings.TrimSpace(source[end+1:]), nil + } + + end := strings.IndexFunc(source, func(r rune) bool { + return unicode.IsSpace(r) || r == ',' + }) + if end < 0 { + return source, "", nil + } + + return source[:end], strings.TrimSpace(source[end:]), nil +} + +func trimOptionalComma(source string) string { + source = strings.TrimSpace(source) + if strings.HasPrefix(source, ",") { + return strings.TrimSpace(source[1:]) + } + + return source +} + +func consumeRenderKeyword(source, keyword string) (string, bool) { + if !strings.HasPrefix(source, keyword) { + return source, false + } + rest := source[len(keyword):] + if rest == "" { + return "", true + } + r, _ := utf8.DecodeRuneInString(rest) + if !unicode.IsSpace(r) { + return source, false + } + + return strings.TrimSpace(rest), true +} + +func parseRenderModifier(source string) (value, alias, params string, err error) { + if source == "" { + return "", "", "", fmt.Errorf("missing value") + } + + asIndex, aliasIndex := findTopLevelAs(source) + commaIndex := findTopLevelParamComma(source) + if asIndex >= 0 && (commaIndex < 0 || asIndex < commaIndex) { + value = strings.TrimSpace(source[:asIndex]) + aliasSource := strings.TrimSpace(source[aliasIndex:]) + var aliasEnd int + alias, aliasEnd = readRenderIdentifier(aliasSource) + if alias == "" { + return "", "", "", fmt.Errorf("missing alias after 'as'") + } + + rest := strings.TrimSpace(aliasSource[aliasEnd:]) + if rest != "" && !strings.HasPrefix(rest, ",") { + return "", "", "", fmt.Errorf("unexpected text after alias: %s", rest) + } + params = trimOptionalComma(rest) + } else if commaIndex >= 0 { + value = strings.TrimSpace(source[:commaIndex]) + params = strings.TrimSpace(source[commaIndex+1:]) + } else { + value = strings.TrimSpace(source) + } + + if value == "" { + return "", "", "", fmt.Errorf("missing value") + } + + return value, alias, params, nil +} + +func findTopLevelAs(source string) (int, int) { + quote := byte(0) + depth := 0 + for i := 0; i < len(source); i++ { + ch := source[i] + if quote != 0 { + if ch == quote && !isEscaped(source, i) { + quote = 0 + } + continue + } + if ch == '\'' || ch == '"' { + quote = ch + continue + } + switch ch { + case '(', '[': + depth++ + case ')', ']': + if depth > 0 { + depth-- + } + } + if depth != 0 || !unicode.IsSpace(rune(ch)) { + continue + } + + keywordStart := i + for keywordStart < len(source) && unicode.IsSpace(rune(source[keywordStart])) { + keywordStart++ + } + if !strings.HasPrefix(source[keywordStart:], "as") { + continue + } + afterKeyword := keywordStart + len("as") + if afterKeyword < len(source) && unicode.IsSpace(rune(source[afterKeyword])) { + return i, afterKeyword + } + } + + return -1, -1 +} + +func findTopLevelParamComma(source string) int { + quote := byte(0) + depth := 0 + for i := 0; i < len(source); i++ { + ch := source[i] + if quote != 0 { + if ch == quote && !isEscaped(source, i) { + quote = 0 + } + continue + } + if ch == '\'' || ch == '"' { + quote = ch + continue + } + switch ch { + case '(', '[': + depth++ + case ')', ']': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 && hasRenderParamPrefix(source[i+1:]) { + return i + } + } + } + + return -1 +} + +func hasRenderParamPrefix(source string) bool { + source = strings.TrimSpace(source) + name, end := readRenderIdentifier(source) + if name == "" { + return false + } + + return strings.HasPrefix(strings.TrimSpace(source[end:]), ":") +} + +func parseRenderParams(source string) ([]renderParam, error) { + var params []renderParam + source = strings.TrimSpace(source) + for source != "" { + name, nameEnd := readRenderIdentifier(source) + if name == "" { + return nil, fmt.Errorf("invalid parameter format (expected 'key: value'): %s", source) + } + + rest := strings.TrimSpace(source[nameEnd:]) + if !strings.HasPrefix(rest, ":") { + return nil, fmt.Errorf("invalid parameter format (expected 'key: value'): %s", source) + } + rest = strings.TrimSpace(rest[1:]) + if rest == "" { + return nil, fmt.Errorf("missing parameter value for '%s'", name) + } + + commaIndex := findTopLevelParamComma(rest) + valueSource := rest + if commaIndex >= 0 { + valueSource = strings.TrimSpace(rest[:commaIndex]) + source = strings.TrimSpace(rest[commaIndex+1:]) + } else { + source = "" + } + + value, err := expressions.Parse(valueSource) + if err != nil { + return nil, fmt.Errorf("invalid parameter value for '%s': %w", name, err) + } + + replaced := false + for i := range params { + if params[i].name == name { + params[i].value = value + replaced = true + break + } + } + if !replaced { + params = append(params, renderParam{name: name, value: value}) + } + } + + return params, nil +} + +func readRenderIdentifier(source string) (string, int) { + end := 0 + for index, r := range source { + valid := unicode.IsLetter(r) || r == '_' || (index > 0 && (unicode.IsDigit(r) || r == '-')) + if !valid { + break + } + end = index + utf8.RuneLen(r) + } + if end == 0 { + return "", 0 + } + + return source[:end], end +} + +func findClosingQuote(source string, start int) int { + quote := source[start] + for i := start + 1; i < len(source); i++ { + if source[i] == quote && !isEscaped(source, i) { + return i + } + } + + return -1 +} + +func isEscaped(source string, index int) bool { + backslashes := 0 + for i := index - 1; i >= 0 && source[i] == '\\'; i-- { + backslashes++ + } + + return backslashes%2 == 1 +} + +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 { + 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) + } + + fileRenderer, ok := ctx.(isolatedFileRenderer) + if !ok { + return ctx.Errorf("render requires isolated file rendering support") + } + + filename := filepath.Join(filepath.Dir(ctx.SourceFile()), templateName) + alias := args.withAlias + if alias == "" { + alias = args.forAlias + } + if alias == "" { + alias = defaultRenderAlias(templateName) + } + + params, err := evaluateRenderParams(ctx, args.params) + if err != nil { + return err + } + if args.forValue != nil { + return renderFor(w, ctx, fileRenderer, filename, alias, params, args.forValue) + } + + scope := maps.Clone(params) + if args.withValue != nil { + var value any + value, err = ctx.Evaluate(args.withValue) + if err != nil { + return err + } + scope[alias] = value + } + + s, err := fileRenderer.RenderFileIsolated(filename, scope) + if err != nil { + return err + } + _, err = io.WriteString(w, s) + + return err + }, nil +} + +func defaultRenderAlias(templateName string) string { + base := path.Base(filepath.ToSlash(templateName)) + extension := path.Ext(base) + if extension != "" && extension != base { + return strings.TrimSuffix(base, extension) + } + + return base +} + +func evaluateRenderParams(ctx render.Context, params []renderParam) (map[string]any, error) { + values := make(map[string]any, len(params)) + for _, param := range params { + value, err := ctx.Evaluate(param.value) + if err != nil { + return nil, fmt.Errorf("error evaluating parameter '%s': %w", param.name, err) + } + values[param.name] = value + } + + return values, nil +} + +func renderFor( + w io.Writer, + ctx render.Context, + fileRenderer isolatedFileRenderer, + filename string, + alias string, + params map[string]any, + collectionExpr expressions.Expression, +) error { + collection, err := ctx.Evaluate(collectionExpr) + if err != nil { + return err + } + + items, ok := renderItems(collection) + if !ok { + return ctx.Errorf("'for' parameter must be an array; got %T", collection) + } + + for i := 0; i < items.Len(); i++ { + scope := maps.Clone(params) + scope[alias] = items.Index(i) + scope["forloop"] = map[string]any{ + "first": i == 0, + "last": i == items.Len()-1, + "index": i + 1, + "index0": i, + "length": items.Len(), + "rindex": items.Len() - i, + "rindex0": items.Len() - i - 1, + } + + s, err := fileRenderer.RenderFileIsolated(filename, scope) + if err != nil { + return err + } + if _, err := io.WriteString(w, s); err != nil { + return err + } + } + + return nil +} + +func renderItems(value any) (iterable, bool) { + if value == nil { + return nil, false + } + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Array && rv.Kind() != reflect.Slice { + return nil, false + } + + return sliceWrapper(rv), true +} diff --git a/tags/render_tag_test.go b/tags/render_tag_test.go new file mode 100644 index 00000000..8c09ecf0 --- /dev/null +++ b/tags/render_tag_test.go @@ -0,0 +1,489 @@ +package tags + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/osteele/liquid/parser" + "github.com/osteele/liquid/render" +) + +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[filepath.Join("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") +} + +func TestRenderTag_DefaultAliases(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: filepath.Join("testdata", "render_source.html"), LineNo: 1} + AddStandardTags(&config) + + config.Cache[filepath.Join("testdata", "product-card.liquid")] = []byte(`{{ product-card.name }}`) + root, err := config.Compile(`{% render "product-card.liquid" with product %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + require.Equal(t, "Widget", buf.String()) + + config.Cache[filepath.Join("testdata", "product-row.liquid")] = []byte(`{{ product-row.name }} `) + root, err = config.Compile(`{% render "product-row.liquid" for products %}`, loc) + require.NoError(t, err) + + buf.Reset() + err = render.Render(root, buf, renderTestBindings, config) + require.NoError(t, err) + require.Equal(t, "Item1 Item2 Item3 ", buf.String()) +} + +func TestRenderTag_ForTypedSlice(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: filepath.Join("testdata", "render_source.html"), LineNo: 1} + AddStandardTags(&config) + config.Cache[filepath.Join("testdata", "typed.liquid")] = []byte(`{{ entry }}`) + + root, err := config.Compile(`{% render "typed.liquid" for values as entry %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, map[string]any{"values": []string{"a", "b", "c"}}, config) + require.NoError(t, err) + require.Equal(t, "abc", buf.String()) +} + +func TestRenderTag_ForEvaluatesParamsOnce(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: filepath.Join("testdata", "render_source.html"), LineNo: 1} + AddStandardTags(&config) + + calls := 0 + config.AddFilter("tick", func(string) int { + calls++ + return calls + }) + config.Cache[filepath.Join("testdata", "counter.liquid")] = []byte(`{{ marker }}`) + + root, err := config.Compile(`{% render "counter.liquid" for values as value, marker: "" | tick %}`, loc) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, map[string]any{"values": []int{1, 2, 3}}, config) + require.NoError(t, err) + require.Equal(t, "111", buf.String()) + require.Equal(t, 1, calls) +} + +func TestRenderTag_FilterWithCommaArgument(t *testing.T) { + config := render.NewConfig() + loc := parser.SourceLoc{Pathname: filepath.Join("testdata", "render_source.html"), LineNo: 1} + AddStandardTags(&config) + config.AddFilter("combine", func(value, left, right string) string { + return value + left + right + }) + config.Cache[filepath.Join("testdata", "filter-args.liquid")] = []byte(`{{ value }} {{ label }}`) + + root, err := config.Compile( + `{% render "filter-args.liquid", value: "x" | combine: "y", "z", label: "ok" %}`, + loc, + ) + require.NoError(t, err) + + buf := new(bytes.Buffer) + err = render.Render(root, buf, nil, config) + require.NoError(t, err) + require.Equal(t, "xyz ok", buf.String()) +} + +// 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) { + root, err := config.Compile(tt.template, loc) + // Some errors might be caught during compilation, others during rendering + if err == nil { + 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[filepath.Join("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[filepath.Join("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[filepath.Join("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.Equal(t, "key", args.params[0].name) + }, + }, + { + 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.Equal(t, "key1", args.params[0].name) + require.Equal(t, "key2", args.params[1].name) + }, + }, + { + name: "quoted commas and escaped quotes", + input: `"template.html", key: "a,\"b", other: value`, + checkFunc: func(t *testing.T, args *renderArgs) { + require.Len(t, args.params, 2) + require.Equal(t, "key", args.params[0].name) + require.Equal(t, "other", args.params[1].name) + }, + }, + { + 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 8989695a..86bd06aa 100644 --- a/tags/standard_tags.go +++ b/tags/standard_tags.go @@ -15,6 +15,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