Using Scriggo Templates with the Go Fiber web framework

Fiber is an express-inspired framework for Go. It uses fasthttp under the hood and has support for many features like middleware, JSON, and template engines among others.

The Fiber project has support for a lot of template engines but they don't have one for Scriggo. I like Scriggo because it looks a bit like Jinja (Python), Twig (PHP) and Pebble (Java) template engines which I have used extensively recently.

Fortunately, Fiber allows you to implement your own Template Engine without much hassle. In this article I will share a simple implementation of a simple Scriggo template engine that you can use with Fiber.

A Simple Server side rendered site in Go

We are going to create a simple server side rendered site with Go using the Scriggo engine. So first of all create a new project, e.g.:

$ mkdir example-site

$ cd example-site

$ go mod init example.com/site

Copy the code for the engine (find it at the bottom of this article) into a file named scriggo_engine/scriggo_engine.go so you can import the Engine.

Now, place the following server code in a file named main.go in your module directory:

package main

import (
    "log"

    "github.com/gofiber/fiber/v2"
    "example.com/site/scriggo_engine"
)

type Server struct {
    app *fiber.App
}

func (s *Server) NotImplemented(ctx *fiber.Ctx) error {
    return nil
}

func NewServer() *Server {
    engine := scriggo_engine.New("templates", ".html")
    s := &Server{
        app: fiber.New(fiber.Config{
            Views: engine,
        }),
    }

    s.app.Get("/", s.indexPage)

    return s
}

type Contact struct {
    Name   string
    Phone  string
    Email    string
}

func (s *Server) indexPage(ctx *fiber.Ctx) error {
    contacts := []Contact{
        {Name: "John Phiri", Email: "john@example.com", Phone: "(+265) 999 123 456"},
        {Name: "Mary Phiri", Email: "mary@example.com", Phone: "(+265) 999 123 456"},
        {Name: "Jane Phiri", Email: "jane@example.com", Phone: "(+265) 999 123 456"},
    }
    return ctx.Render("index", fiber.Map{
        "contacts": &contacts,
    })
}

func (s *Server) Start(bind string) error {
    return s.app.Listen(bind)
}

func main() {
    server := NewServer()
    err := server.Start("localhost:3000")
    if err != nil {
        log.Fatalf("Failed to run, got error %v", err)
    }
}

In a file named templates/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ Title() }} - My Website</title>
</head>
<body>
    <nav>
        <h1>My Website</h1>

        <ul>
            <li><a href="/home">Home</a></li>
            <li><a href="/about">About</a></li>
            <li><a href="/contact">Contact</a></li>
        </ul>
    </nav>
    {{ Content() }}
    {{ Footer() }}
</body>
</html>

In a file named templates/index.html

{% extends "base.html" %}
{% macro Title %}Home{% end %}
{% macro Content %}
<section id="app-content">
    <div class="contacts">
        {% for contact in contacts %}
        <li><a href="/contact/{{ contact.Name }}">{{ contact.Phone}}</a> ({{ contact.Email }})</li>
        {% end %}
    </div>
</section>
{% end %}
{% macro Footer %}
 <footer>(c) My Website</footer>
{% end %}

Project Structure

Your directory should look something like this

example-site
├───main.go
├───go.mod
├───go.sum
├───scriggo_engine
|    └───scriggo_engine.go
└───templates
    ├───base.html
    └───index.html

Running the code

First we are going to make sure go modules are tidied and downloaded via go mod tidy and then we can run the main.go

$ go mod tidy

$ go run main.go

Now go to http://localhost:3000 you should see a server-side rendered HTML page!

Registering Scriggo in-built functions

Typically in template engines we may want to call some functions on our data. Scriggo comes with a lot of built-in functions and using them is relatively straight-foward.

Register the function via addFunc

import "github.com/open2b/scriggo/builtin"

// ..
engine.AddFunc("base64", builtin.Base64 )
// ..

Use the function in your templates

<div>{{ base64("Hello") }}</div>

Scriggo Template Engine for Fiber

Here is the implementation of the engine code, it has not been battle-tested so if there are bugs or ways to improve it, feel free to reach out on Twitter.

package scriggo_engine

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "strings"
    "sync"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/template/utils"
    "github.com/open2b/scriggo"
    "github.com/open2b/scriggo/native"
)

// Engine struct
type Engine struct {
    // views folder
    directory string
    // http.FileSystem supports embedded files
    fileSystem http.FileSystem
    // views extension
    extension string
    // layout variable name that incapsulates the template
    layout string
    // determines if the engine parsed all templates
    loaded bool
    // reload on each render
    reload bool
    // debug prints the parsed templates
    debug bool
    // lock for funcmap and templates
    mutex sync.RWMutex
    // template funcmap
    funcmap native.Declarations
    // templates
    templates map[string]string
    // scriggo filesystem
    fsys scriggo.Files
}

// New returns a Scriggo render engine for Fiber
func New(directory, extension string) *Engine {
    engine := &Engine{
        directory: directory,
        extension: extension,
        layout:    "embed",
        funcmap:   make(native.Declarations),
    }
    engine.AddFunc(engine.layout, func() error {
        return fmt.Errorf("layout called unexpectedly.")
    })
    return engine
}

func NewFileSystem(fs http.FileSystem, extension string) *Engine {
    engine := &Engine{
        directory:  "/",
        fileSystem: fs,
        extension:  extension,
        layout:     "embed",
        funcmap:    make(native.Declarations),
    }
    engine.AddFunc(engine.layout, func() error {
        return fmt.Errorf("layout called unexpectedly.")
    })
    return engine
}

// Layout defines the variable name that will incapsulate the template
func (e *Engine) Layout(key string) *Engine {
    e.layout = key
    return e
}

// Delims sets the action delimiters to the specified strings, to be used in
// templates. An empty delimiter stands for the
// corresponding default: {{ or }}.
func (e *Engine) Delims(left, right string) *Engine {
    fmt.Println("delims: this method is not supported for scriggo")
    return e
}

// AddFunc adds the function to the template's function map.
// It is legal to overwrite elements of the default actions
func (e *Engine) AddFunc(name string, fn native.Declaration) *Engine {
    e.mutex.Lock()
    e.funcmap[name] = fn
    e.mutex.Unlock()
    return e
}

// Reload if set to true the templates are reloading on each render,
// use it when you're in development and you don't want to restart
// the application when you edit a template file.
func (e *Engine) Reload(enabled bool) *Engine {
    e.reload = enabled
    return e
}

// Debug will print the parsed templates when Load is triggered.
func (e *Engine) Debug(enabled bool) *Engine {
    e.debug = enabled
    return e
}

// Load parses the templates to the engine.
func (e *Engine) Load() error {
    // race safe
    e.mutex.Lock()
    defer e.mutex.Unlock()

    e.templates = make(map[string]string)
    e.fsys = scriggo.Files{}

    // Loop trough each directory and register template files
    walkFn := func(path string, info os.FileInfo, err error) error {
        // Return error if exist
        if err != nil {
            return err
        }
        // Skip file if it's a directory or has no file info
        if info == nil || info.IsDir() {
            return nil
        }
        // Skip file if it does not equal the given template extension
        if len(e.extension) >= len(path) || path[len(path)-len(e.extension):] != e.extension {
            return nil
        }
        // Get the relative file path
        // ./views/html/index.tmpl -> index.tmpl
        rel, err := filepath.Rel(e.directory, path)
        if err != nil {
            return err
        }
        // Reverse slashes '\' -> '/' and
        // partials\footer.tmpl -> partials/footer.tmpl
        name := filepath.ToSlash(rel)
        // Remove ext from name 'index.tmpl' -> 'index'
        name = strings.TrimSuffix(name, e.extension)
        // name = strings.Replace(name, e.extension, "", -1)
        // Read the file
        // #gosec G304
        buf, err := utils.ReadFile(path, e.fileSystem)
        if err != nil {
            return err
        }

        e.fsys[filepath.ToSlash(rel)] = buf
        e.templates[name] = filepath.ToSlash(rel)

        // Debugging
        if e.debug {
            fmt.Printf("views: parsed template: %s\n", name)
        }
        return err
    }
    // notify engine that we parsed all templates
    e.loaded = true
    if e.fileSystem != nil {
        return utils.Walk(e.fileSystem, e.directory, walkFn)
    }
    return filepath.Walk(e.directory, walkFn)
}

// Render will execute the template name along with the given values.
func (e *Engine) Render(out io.Writer, template string, binding interface{}, layout ...string) error {
    if !e.loaded || e.reload {
        if e.reload {
            e.loaded = false
        }
        if err := e.Load(); err != nil {
            return err
        }
    }
    templatePath, ok := e.templates[template]
    if !ok {
        return fmt.Errorf("render: template %s does not exist", template)
    }

    opts := &scriggo.BuildOptions{
        Globals: e.funcmap,
    }

    // Register the variables as Globals for Scriggo's type checking to work
    for key, value := range binding.(fiber.Map) {
        opts.Globals[key] = value
    }

    // Build the template.
    tmpl, err := scriggo.BuildTemplate(e.fsys, templatePath, opts)
    if err != nil {
        return err
    }

    return tmpl.Run(out, nil, nil)
}

What's next?

  1. Support for embedded filesytems (embed.FS) - I suspect it's already possible but the API doesn't look good for doing so yet.

  2. Releasing to the public - I am thinking of either releasing this as a module that others can consume or sending a Pull Request to the Fiber team if they would be interested, or I may just make it a Gist. We will see. Right now the proof of concept works well enough for me to play with on toy projects.

I may update this article later on