A go template renderer based on Perl's Template Toolkit
Go 99.3%
Makefile 0.7%
13 1 3

Clone this repository

https://tangled.org/angrydutchman.peedee.es/gott
git@tangled.org:angrydutchman.peedee.es/gott

For self-hosted knots, clone URLs may differ based on your setup.

README.md

GOTT - Go Template Toolkit#

A templating system very much inspired by Perl's Template module (The Template Toolkit). There are other templating packages available, of course, but I really wanted the [% WRAPPER %], [% BLOCK %] and [% INCLUDE %] directives.

This is just a pet project I built for myself, to see if a) I could actually understand/write Go, and b) to see what value a tool like Claude brings to the table. So, YMMV!

Usage#

Import the package, and instantiate a new renderer:

renderer := gott.New(&gott.Config{
    ...
})

The gott.Config contains the following fields:

  • IncludePaths: []fs.FS
  • Filters: map[string]func(string, ...string) string

The include paths are tried left to right when a template needs rendering; this can be used for instance to do this:

renderer := gott.New(&gott.Config{
    IncludePaths: []fs.FS{
        os.DirFS("..."),
        embeddedFS
    }
})

To have files on disk tried first, and if the requested template wasn't found, fall back to an embedded FS (via embed).

Filters are functions that filter variables in templates, for instance one could write a filter that turns everything to upper, or to truncate a string to a given length.

import "strings"

func filterUpper(content string, args ...string) string {
    return strings.ToUpper(content)
}

func filterTruncate(s string, args ...string) string {
    if len(args) == 0 {
        return s
    }
    maxLen := 0
    fmt.Sscanf(args[0], "%d", &maxLen)
    if maxLen <= 0 || len(s) <= maxLen {
        return s
    }
    return s[:maxLen-3] + "..."
}
renderer := gott.New(&gott.Config{
    Filters: map[string]func(string, ...string) string{
        upper: filterUpper,
        truncate: filterTruncate,
    }
})

For convenience. the renderer has an AddFilter method:

renderer.AddFilter("truncate", filterTruncate)

To render content, there are a few methods available:

// Render takes a http.ResponseWriter, string, and a map[string]any and will attempt to render the given template
// and writes it to the client as text/html; a 404 error is rendered for templates that aren't found, 500 error is 
// rendered if something goes kaka
renderer.Render(w, "mytemplate", map[string]any{myvariable:"foo", userCount: 10})

// Identical to Render except it lets you override the status code; I have one project that actually renders html
// with a 420 (made up) status code because reasons. Something tech debt-ish. 
renderer.RenderWithStatus(w, int, string, map[string]any) 

// Render JSON data, will render a 500 error if marshalling failed; takes a http.ResponseWriter and 'any' as parameter
renderer.RenderJSON(w, map[string]any{status:"error",errorCode: 200, errorMessage: "it done blew up"})

// Same as above but takes the ResponseWriter, status code, and the data that needs to be marshalled
renderer.RenderJSONWithStatus(w, int, any) 

// Silly little function to render a blank HTTP 204 response 
renderer.Render204() 

Concepts#

Filters#

Filters are simple functions that transform variable content. These should be short and to the point, and should not be used to perform any sort of external lookup or other network requests. Filters can take parameters (e.g. truncate(30)) but all parameters are passed as strings, so you'll have to do the proper processing on them in the filter func.

The following filters are built-in:

  • upper: turns the variable to all uppercase
  • lower: turns the variable to all lowercase
  • html: makes the variable safe to render in HTML (e.g. "" is turned into ">foo<"
  • uri: makes the variable safe to use as an URI fragment;

Virtual Methods#

Virtual methods are, well, methods that one can call on a variable. For instance, given a variable of signature []string{"foo","bar","baz"}, you can use this construct to ge the last element: [% variable.last %].

The following virtual methods are built-in:

  • exists: Will return true if the given variable actually exists in the data passed to the renderer, does not check for defined-ness, purely existence.
  • defined: Will return true if the given variable exists in the data passed to the renderer, and is not empty (insofar a variable can be empty in go)
  • length/size: Will return the length of the variable; handles strings, slices, maps
  • first/last: Will return the first or last element of a slice

You can add your own virtual methods with the AddVirtualMethod method on the renderer, for instance:

renderer.addVirtualMethod("methodname", func(data any) (any, bool) {
    
})

A virtual method handler should return whatever data it wants, plus a boolean indicating whether the operation was successful.

Variables and interpolation#

Variables are passed to the renderer as a map[string]any and can be interpolated into a template via the following syntax:

// simple variable
[% myvariable %]

// nested variable, e.g. variable is a map
[% myvariable.somekey %]

// but it could also be a vmethod 
[% myvariable.length %]

// if variable is not defined, add a default
[% user.name || "User doesn't have a name, strange!" %]

// variable with filtering
[% userEnteredHTML |html %] 

// filter chaining
[% verylongname |truncate(20)|upper %] 

// vmethods and defaults and filters, oh my (this would work as you think it would because a value of 0
// is considered "not truthy"
[% records.length || "no records found" |somefilter|anotherfilter %]  

The following comparison operators are available: == != < <= => > The following logic operators are available: && || The following arithmetic operators are available: + - * / %

Strings can be concatenated using + if either the lvalue or rvalue is a string. Given the following data:

data := map[string]any{version: "1.2.3", versionNumeric: 123}

// renders "1.2.3-dev"
[% version + "-dev" %] 

// renders "123-dev" 
[% versionNumeric + "-dev" %]

// renders 124
[% versionNumeric + 1 %]

// renders 1-1.2.3 
[% "1-" + version %]

Directives#

The following directives are supported:

// logic
[% IF myvariable == "yes" %] 
    Yes!
[% ELSIF myvariable == "maybe" %]
    Maybe?
[% ELSE %]
    No...
[% END %]

// this construct is a Perlism
[% UNLESS user.isAdmin %]
    Not the admin
[% END %]

// include another template
[% INCLUDE other/template.html %]

// include a previously defined block
[% INCLUDE myshinyblock %]

// set a variable
[% SET myvar = "foo" %]

// include a template based on a variable path (would expand to my/foo/template.html
[% INCLUDE my/$myvar/template.html %] 

// try/catch 
[% TRY %]
    [% INCLUDE my/$myvar/template.html %]
[% CATCH %]
    [% INCLUDE bog/standard/template.html %]
[% END %]

[% TRY %]
    [% INCLUDE anundefinedblock %]
[% CATCH %]
    oops!
[% END %]


// define a block
[% BLOCK myshinyblock %]
    [% entry.name %]
[% END %]

// iterate a slice; a foreach can be nested, and a foreach can use all other directives in it's repeated block 
[% FOREACH entry IN myslice %]
    [% INCLUDE myshinyblock %]
[% END %]

// iterate a map
[% FOREACH entry IN mymap %]
    [% entry.key %] = [% entry.value %]
[% END %]

// wrap content in another template
// wrapper template content should contain a single `[% content %]` variable where the content it is wrapping will appear
[% WRAPPER wrap/me/in/this.html %]
    content that can use any other directive
[% END %]

Author/Disclosure#

Author: Ben van Staveren/AngryDutchman ben@blockstackers.net Co-Author: Claude (see below)

Heavily inspired by Perl's Template Toolkit, by Andy Wardley

Claude usage#

Claude is used to add comments in places that should have them but don't (because I'm awful at doing it), and to take my not-so-efficient implementations and make them more efficient. Claude is also used to solve fun directive parsing problems, and to do refactoring work. All code generated by Claude is vetted and looked at.