diff --git a/.golangci.yaml b/.golangci.yaml index dfd31b3..61bf4f2 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -15,6 +15,7 @@ linters: - golint - maligned - gci + - depguard # deprecated/archived - interfacer - scopelint diff --git a/README.md b/README.md index bfba5ae..0bbb811 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,38 @@ This is done by calling the following functions from within templates and script * `templateString(templateFile string, data any) (string, error)` - Start a template file and return the rendered template as a string. * `templateFile` (string) - The path to the template file to start the engine from. * `data` (any) - Context data to provide to templates and scripts. Available as `{{.Local}}` in templates and `context.Local` in scripts. +* `templateStringInput(templateName string, templateString string, data any) (string, error)` - Template the input string and return the rendered template as a string. + * `templateName` (string) - The name of the template to render. + * `templateString` (string) - An input template string to template. + * `data` (any) - Context data to provide to templates and scripts. Available as `{{.Local}}` in templates and `context.Local` in scripts. +* `recurse(recursions int) string` - Recurse the current template file, recursions is the number of times to recurse the template file. + * `recursions` (int) - The number of times to recurse the template file. This allows for example: ```gotemplate {{ templateFile "tmpl.stmpl" "out.txt" .Local }}{{/* Template another file */}} {{ templateString "tmpl.stmpl" .Local }}{{/* Template another file and include the rendered output in this templates rendered output */}} +{{ templateStringInput "Hello {{ .Local.name }}" .Local }}{{/* Template a string and include the rendered output in this templates rendered output */}} ``` +#### Recursive templating + +It is possible with the `recurse` function in a template to render the same template multiple times. This can be useful when data to render parts of the template are only available after you have rendered it at least once. + +For example: + +```go +{{- recurse 1 -}} +{{"{{.RecursiveComputed.Names}}"}}{{/* Render the names of the customers after we have iterated over them later */}} +{{range .Local.Customers}} +{{- addName .RecursiveComputed.Names (print .FirstName " " .LastName) -}} +{{.FirstName}} {{.LastName}} +{{end}} +``` + +Note: The `recurse` function must be called as the first thing in the template on its own line. + ### Registering templating functions The engine allows you to register custom templating functions from Go which can be used within the templates. @@ -184,8 +208,9 @@ sjs``` The `sjs` snippet can be used anywhere within your template (including multiple snippets) and will be replaced with any "rendered" output returned when using the `render` function. Naive transformation of typescript code is supported through [esbuild](https://esbuild.github.io/api/#transformation). This means that you can directly import typescript code and use type annotations in place of any JavaScript. However, be aware: - * EasyTemplate will not perform type checking itself. Type annotations are transformed into commented out code. - * Scripts/Snippets are not bundled, but executed as a single module on the global scope. This means no `import` statements are possible. [Instead, the global `require` function](#importing-javascript) is available to directly execute JS/TS code. + +* EasyTemplate will not perform type checking itself. Type annotations are transformed into commented out code. +* Scripts/Snippets are not bundled, but executed as a single module on the global scope. This means no `import` statements are possible. [Instead, the global `require` function](#importing-javascript) is available to directly execute JS/TS code. ### Context data @@ -305,6 +330,10 @@ The following functions are available to JavaScript from the templating engine: * `templateString(templateString, data)` - Render a template and return the rendered output. * `templateString` (string) - The template string to render. * `data` (object) - Data available to the template as `Local` context ie `{name: "John"}` is available as `{{ .Local.name }}`. +* `templateStringInput(templateName, templateString, data)` - Render a template and return the rendered output. + * `templateName` (string) - The name of the template to render. + * `templateString` (string) - The template string to render. + * `data` (object) - Data available to the template as `Local` context ie `{name: "John"}` is available as `{{ .Local.name }}`. * `render(output)` - Render the output to the template file, if called multiples times the output will be appended to the previous output as a new line. The cumulative output will replace the current `sjs` block in the template file. * `output` (string) - The output to render. * `require(filePath)` - Import a JavaScript file into the global scope. diff --git a/engine.go b/engine.go index e618aad..27a8189 100644 --- a/engine.go +++ b/engine.go @@ -129,6 +129,7 @@ func New(opts ...Opt) *Engine { e.jsFuncs = map[string]func(call CallContext) goja.Value{ "require": e.require, + "recurse": e.recurseJS, "templateFile": e.templateFileJS, "templateString": e.templateStringJS, "templateStringInput": e.templateStringInputJS, @@ -290,6 +291,16 @@ func (e *Engine) init(data any) (*vm.VM, error) { return templated, nil } }(v) + e.templator.TmplFuncs["recurse"] = func(v *vm.VM) func(int) (string, error) { + return func(numTimes int) (string, error) { + templated, err := e.templator.Recurse(v, numTimes) + if err != nil { + return "", err + } + + return templated, nil + } + }(v) if _, err := v.Run("initCreateComputedContextObject", `function createComputedContextObject() { return {}; }`); err != nil { return nil, utils.HandleJSError("failed to init createComputedContextObject", err) diff --git a/engine_integration_test.go b/engine_integration_test.go index 4f18573..bbc65a1 100644 --- a/engine_integration_test.go +++ b/engine_integration_test.go @@ -54,7 +54,7 @@ func TestEngine_RunScript_Success(t *testing.T) { err = e.RunScript("scripts/test.js", map[string]interface{}{ "Test": "global", }) - assert.NoError(t, err) + require.NoError(t, err) assert.Empty(t, expectedFiles, "not all expected files were written") } diff --git a/go.mod b/go.mod index 387f2eb..c6e209b 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/speakeasy-api/easytemplate go 1.19 require ( - github.com/dop251/goja v0.0.0-20231024180952-594410467bc6 - github.com/dop251/goja_nodejs v0.0.0-20221211191749-434192f0843e - github.com/evanw/esbuild v0.17.8 + github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d + github.com/dop251/goja_nodejs v0.0.0-20231022114343-5c1f9037c9ab + github.com/evanw/esbuild v0.19.5 github.com/go-sourcemap/sourcemap v2.1.3+incompatible github.com/golang/mock v1.6.0 github.com/stretchr/testify v1.8.1 @@ -13,10 +13,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.7.0 // indirect - github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.3.0 // indirect - golang.org/x/text v0.5.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/text v0.13.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8ed121f..9964c38 100644 --- a/go.sum +++ b/go.sum @@ -6,24 +6,25 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= -github.com/dop251/goja v0.0.0-20231024180952-594410467bc6 h1:U9bRrSlYCu0P8hMulhIdYpr5HUao66tKPdNgD88Zi5M= -github.com/dop251/goja v0.0.0-20231024180952-594410467bc6/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= -github.com/dop251/goja_nodejs v0.0.0-20221211191749-434192f0843e h1:DJ5cKH4HUYevCd09vMIbwc8U02eBKLFR2q1O1hSAJcY= -github.com/dop251/goja_nodejs v0.0.0-20221211191749-434192f0843e/go.mod h1:0tlktQL7yHfYEtjcRGi/eiOkbDR5XF7gyFFvbC5//E0= -github.com/evanw/esbuild v0.17.8 h1:QzE7cRRq7y3qH7ZKGN0/nUGbdZPyikfNaMaCMNUufnU= -github.com/evanw/esbuild v0.17.8/go.mod h1:iINY06rn799hi48UqEnaQvVfZWe6W9bET78LbvN8VWk= +github.com/dop251/goja_nodejs v0.0.0-20231022114343-5c1f9037c9ab h1:LrVf0AFnp5WiGKJ0a6cFf4RwNIN327uNUeVGJtmAFEE= +github.com/dop251/goja_nodejs v0.0.0-20231022114343-5c1f9037c9ab/go.mod h1:bhGPmCgCCTSRfiMYWjpS46IDo9EUZXlsuUaPXSWGbv0= +github.com/evanw/esbuild v0.19.5 h1:9ildZqajUJzDAwNf9MyQsLh2RdDRKTq3kcyyzhE39us= +github.com/evanw/esbuild v0.19.5/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ= +github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -56,7 +57,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -70,18 +70,17 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= diff --git a/internal/template/template.go b/internal/template/template.go index a89eb3b..9feba05 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -27,17 +27,19 @@ var sjsRegex = regexp.MustCompile("(?ms)(```sjs\\s*\\n*(.*?)sjs```)") // Context is the context that is passed templates or js. type Context struct { - Global any - Local any - GlobalComputed goja.Value - LocalComputed goja.Value + Global any + Local any + GlobalComputed goja.Value + LocalComputed goja.Value + RecursiveComputed goja.Value } type tmplContext struct { - Global any - Local any - GlobalComputed any - LocalComputed any + Global any + Local any + GlobalComputed any + LocalComputed any + RecursiveComputed any } // VM represents a virtual machine that can be used to run js. @@ -104,6 +106,8 @@ func (t *Templator) TemplateString(vm VM, templatePath string, inputData any) (o } // TemplateStringInput will template the provided input string and return the output as a string. +// +//nolint:funlen func (t *Templator) TemplateStringInput(vm VM, name string, input string, inputData any) (out string, err error) { defer func() { if e := recover(); e != nil { @@ -116,37 +120,69 @@ func (t *Templator) TemplateStringInput(vm VM, name string, input string, inputD return "", utils.HandleJSError("failed to create local computed context", err) } - context := &Context{ - Global: t.contextData, - GlobalComputed: t.globalComputed, - Local: inputData, - LocalComputed: localComputed, - } - currentContext := vm.Get("context") - if err := vm.Set("context", context); err != nil { - return "", fmt.Errorf("failed to set context: %w", err) - } + currentRecursiveComputed := getRecursiveComputedContext(vm) + localRecursiveComputed := currentRecursiveComputed - evaluated, replacedLines, err := t.evaluateInlineScripts(vm, name, input) + numIterations := 1 + numRecursions, err := t.isRecursiveTemplate(input) if err != nil { return "", err } + if numRecursions > 0 { + numIterations = numRecursions + 1 + localRecursiveComputed, err = vm.Run("recursiveCreateComputedContextObject", `createComputedContextObject();`) + if err != nil { + return "", utils.HandleJSError("failed to create recursive computed context", err) + } + } - // Get the computed context back as it might have been modified by the inline script - localComputed = getComputedContext(vm) + for i := 0; i < numIterations; i++ { + context := &Context{ + Global: t.contextData, + GlobalComputed: t.globalComputed, + Local: inputData, + LocalComputed: localComputed, + RecursiveComputed: localRecursiveComputed, + } - tmplCtx := &tmplContext{ - Global: context.Global, - Local: context.Local, - GlobalComputed: context.GlobalComputed.Export(), - LocalComputed: localComputed.Export(), - } + if err := vm.Set("context", context); err != nil { + return "", fmt.Errorf("failed to set context: %w", err) + } - out, err = t.execTemplate(name, evaluated, tmplCtx, replacedLines) - if err != nil { - return "", err + evaluated, replacedLines, err := t.evaluateInlineScripts(vm, name, input) + if err != nil { + return "", err + } + + // Get the computed context back as it might have been modified by the inline script + localComputed = getLocalComputedContext(vm) + + tmplCtx := &tmplContext{ + Global: context.Global, + Local: context.Local, + GlobalComputed: context.GlobalComputed.Export(), + LocalComputed: localComputed.Export(), + RecursiveComputed: localRecursiveComputed.Export(), + } + + out, err = t.execTemplate(name, evaluated, tmplCtx, replacedLines) + if err != nil { + return "", err + } + + // Set the output as the input for the next iteration and update the computed context + var cont bool + input, cont, err = t.applyRecurseCanary(out) + if err != nil { + return "", err + } + // If the output is the same as the input, or no canary found, we don't need to continue + if !cont || evaluated == out { + break + } + localRecursiveComputed = getRecursiveComputedContext(vm) } // Reset the context back to the previous one @@ -201,7 +237,7 @@ func (t *Templator) execSJSBlock(v VM, js, templatePath string, jsBlockLineNumbe return strings.Join(c.renderedContent, "\n"), nil } -func getComputedContext(vm VM) goja.Value { +func getLocalComputedContext(vm VM) goja.Value { // Get the local context back as it might have been modified by the inline script contextVal := vm.Get("context") @@ -210,6 +246,17 @@ func getComputedContext(vm VM) goja.Value { return computedVal } +func getRecursiveComputedContext(vm VM) goja.Value { + contextVal := vm.Get("context") + if contextVal == goja.Undefined() { + return goja.Undefined() + } + + computedVal := vm.ToObject(contextVal).Get("RecursiveComputed") + + return computedVal +} + func (t *Templator) execTemplate(name string, tmplContent string, data any, replacedLines int) (string, error) { tmp, err := template.New(name).Funcs(t.TmplFuncs).Parse(tmplContent) if err != nil { @@ -226,6 +273,74 @@ func (t *Templator) execTemplate(name string, tmplContent string, data any, repl return buf.String(), nil } +// Recurse will let the engine know how many times the template should execute. +func (t *Templator) Recurse(_ VM, numTimes int) (out string, err error) { + if numTimes < 1 { + return "", fmt.Errorf("recurse(%d) invalid: must recurse at least once", numTimes) + } + + return fmt.Sprintf(canaryPlaceholder, strconv.Itoa(numTimes-1)), nil +} + +var ( + canaryPlaceholder = "~-~SPEAKEASY_RECURSE_CANARY_%s~-~" + canaryRegex = regexp.MustCompile(fmt.Sprintf(canaryPlaceholder, `(\d+)`)) + recurseRegex = regexp.MustCompile(`^{{-* *recurse (\d+) *-*}}$`) +) + +func (t *Templator) isRecursiveTemplate(input string) (int, error) { + lines := strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") + + matches := recurseRegex.FindAllStringSubmatch(lines[0], -1) + if len(matches) != 1 { + return -1, nil + } + + num, err := strconv.Atoi(matches[0][1]) + if err != nil { + return 0, err + } + + return num, nil +} + +func (t *Templator) getCanaryNum(input string) (int, error) { + matches := canaryRegex.FindAllStringSubmatch(input, -1) + if len(matches) == 0 { + return -1, nil + } + + if len(matches) > 1 { + return 0, fmt.Errorf("recurse canary found more than once in template. recurse should only be declared once per template") + } + + num, err := strconv.Atoi(matches[0][1]) + if err != nil { + return 0, err + } + + return num, nil +} + +func (t *Templator) applyRecurseCanary(input string) (string, bool, error) { + num, err := t.getCanaryNum(input) + if err != nil { + return "", false, err + } + + if num == -1 { + return input, false, nil + } + + replacementString := "" + + if num > 0 { + replacementString = fmt.Sprintf(canaryPlaceholder, strconv.Itoa(num-1)) + } + + return strings.Replace(input, fmt.Sprintf(canaryPlaceholder, strconv.Itoa(num)), replacementString, 1), true, nil +} + func adjustLineNumber(name string, err error, replacedLines int) error { lineNumRegex, rErr := regexp.Compile(fmt.Sprintf(`template: %s:(\d+)`, regexp.QuoteMeta(name))) if rErr == nil { @@ -239,7 +354,7 @@ func adjustLineNumber(name string, err error, replacedLines int) error { return matches[0], nil //nolint:nilerr } - return strings.Replace(matches[0], matches[1], fmt.Sprintf("%d", currentLineNumber+replacedLines), 1), nil + return strings.Replace(matches[0], matches[1], strconv.Itoa(currentLineNumber+replacedLines), 1), nil }) if rErr == nil { err = fmt.Errorf(errMsg) diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 390ec57..ed211e2 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -8,6 +8,7 @@ import ( "github.com/speakeasy-api/easytemplate/internal/template" "github.com/speakeasy-api/easytemplate/internal/template/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTemplator_TemplateFile_Success(t *testing.T) { @@ -47,16 +48,17 @@ func TestTemplator_TemplateFile_Success(t *testing.T) { vm := mocks.NewMockVM(ctrl) context := &template.Context{ - Global: tt.fields.contextData, - GlobalComputed: goja.Undefined(), - Local: tt.args.inputData, - LocalComputed: goja.Undefined(), + Global: tt.fields.contextData, + GlobalComputed: goja.Undefined(), + Local: tt.args.inputData, + LocalComputed: goja.Undefined(), + RecursiveComputed: goja.Undefined(), } o := goja.New() contextVal := o.ToValue(context) vm.EXPECT().Run("localCreateComputedContextObject", `createComputedContextObject();`).Return(goja.Undefined(), nil).Times(1) - vm.EXPECT().Get("context").Return(goja.Undefined()).Times(1) + vm.EXPECT().Get("context").Return(goja.Undefined()).Times(2) vm.EXPECT().Set("context", context).Return(nil).Times(1) vm.EXPECT().Get("context").Return(contextVal).Times(1) vm.EXPECT().ToObject(contextVal).Return(contextVal.ToObject(o)).Times(1) @@ -75,7 +77,7 @@ func TestTemplator_TemplateFile_Success(t *testing.T) { } tr.SetContextData(tt.fields.contextData, goja.Undefined()) err := tr.TemplateFile(vm, tt.args.templatePath, tt.args.outFile, tt.args.inputData) - assert.NoError(t, err) + require.NoError(t, err) }) } } @@ -146,16 +148,17 @@ func TestTemplator_TemplateString_Success(t *testing.T) { vm := mocks.NewMockVM(ctrl) context := &template.Context{ - Global: tt.fields.contextData, - GlobalComputed: goja.Undefined(), - Local: tt.args.inputData, - LocalComputed: goja.Undefined(), + Global: tt.fields.contextData, + GlobalComputed: goja.Undefined(), + Local: tt.args.inputData, + LocalComputed: goja.Undefined(), + RecursiveComputed: goja.Undefined(), } o := goja.New() contextVal := o.ToValue(context) vm.EXPECT().Run("localCreateComputedContextObject", `createComputedContextObject();`).Return(goja.Undefined(), nil).Times(1) - vm.EXPECT().Get("context").Return(goja.Undefined()).Times(1) + vm.EXPECT().Get("context").Return(goja.Undefined()).Times(2) vm.EXPECT().Set("context", context).Return(nil).Times(1) if tt.fields.includedJS != "" { @@ -178,7 +181,7 @@ func TestTemplator_TemplateString_Success(t *testing.T) { } tr.SetContextData(tt.fields.contextData, goja.Undefined()) out, err := tr.TemplateString(vm, tt.args.templatePath, tt.args.inputData) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.wantOut, out) }) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index a52b68b..39b9872 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -14,6 +14,7 @@ func ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str result := "" lastIndex := 0 + //nolint:mirror for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { groups := []string{} for i := 0; i < len(v); i += 2 { diff --git a/templating.go b/templating.go index 72f353d..2634320 100644 --- a/templating.go +++ b/templating.go @@ -35,3 +35,12 @@ func (e *Engine) templateStringInputJS(call CallContext) goja.Value { return call.VM.ToValue(output) } + +func (e *Engine) recurseJS(call CallContext) goja.Value { + output, err := e.templator.Recurse(call.VM, int(call.Argument(0).ToInteger())) + if err != nil { + panic(call.VM.NewGoError(err)) + } + + return call.VM.ToValue(output) +} diff --git a/testdata/expected/test.txt b/testdata/expected/test.txt index 80e330b..cb12292 100644 --- a/testdata/expected/test.txt +++ b/testdata/expected/test.txt @@ -1,4 +1,9 @@ global from test.js -34.000 \ No newline at end of file +34.000 +Hello User +Hello World it's a wonderful day! The End! +A recursive template with its own scope +I have my own recursive scope! +I'm a sub template that will add to my parents template recursive context diff --git a/testdata/templates/recursiveSubTemplate.stmpl b/testdata/templates/recursiveSubTemplate.stmpl new file mode 100644 index 0000000..a559d31 --- /dev/null +++ b/testdata/templates/recursiveSubTemplate.stmpl @@ -0,0 +1,12 @@ +{{recurse 2 -}} +A recursive template with its own scope +{{"{{\"{{.RecursiveComputed.AccumulatedString}}\"}}"}}{{/* Double escaped so it will unfurl fully by the third iteration to template*/}} +```sjs +if (!context.RecursiveComputed.AccumulatedString) { + context.RecursiveComputed.AccumulatedString = ""; +} +context.RecursiveComputed.AccumulatedString += "I have my "; +sjs``` +{{- "```"}}{{"sjs"}}{{/* Escaped so it will only be executed on the second iteration */}} +context.RecursiveComputed.AccumulatedString += "own recursive scope!"; +{{"sjs"}}{{"```"}} \ No newline at end of file diff --git a/testdata/templates/test.stmpl b/testdata/templates/test.stmpl index b16162f..b96a356 100644 --- a/testdata/templates/test.stmpl +++ b/testdata/templates/test.stmpl @@ -1,4 +1,6 @@ {{.Global.Test}} {{.Local.Test}} {{templateFile "templates/test2.stmpl" "test2.txt" "from test.stmpl"}} -{{toFloatWithPrecision .Local.Value 3}} \ No newline at end of file +{{toFloatWithPrecision .Local.Value 3}} +{{templateStringInput "hello" "Hello {{.Local}}" "User"}} +{{templateString "templates/testMultiple.stmpl" nil}} \ No newline at end of file diff --git a/testdata/templates/testMultiple.stmpl b/testdata/templates/testMultiple.stmpl new file mode 100644 index 0000000..08cdeef --- /dev/null +++ b/testdata/templates/testMultiple.stmpl @@ -0,0 +1,13 @@ +{{- recurse 2 -}} +{{"{{\"{{.RecursiveComputed.AccumulatedString}}\"}}"}}{{/* Double escaped so it will unfurl fully by the third iteration to template*/}} +```sjs +if (!context.RecursiveComputed.AccumulatedString) { + context.RecursiveComputed.AccumulatedString = ""; +} +context.RecursiveComputed.AccumulatedString += "Hello World"; +sjs``` +{{- "```"}}{{"sjs"}}{{/* Escaped so it will only be executed on the second iteration */}} +context.RecursiveComputed.AccumulatedString += " The End!"; +{{"sjs"}}{{"```"}} +{{templateString "templates/recursiveSubTemplate.stmpl" nil}} +{{templateString "templates/testMultipleSubTemplate.stmpl" nil}} \ No newline at end of file diff --git a/testdata/templates/testMultipleSubTemplate.stmpl b/testdata/templates/testMultipleSubTemplate.stmpl new file mode 100644 index 0000000..51ab583 --- /dev/null +++ b/testdata/templates/testMultipleSubTemplate.stmpl @@ -0,0 +1,4 @@ +I'm a sub template that will add to my parents template recursive context +```sjs +context.RecursiveComputed.AccumulatedString += " it's a wonderful day!"; +sjs``` \ No newline at end of file