Custom Leaf tags in Vapor 4

In this article I'm going to show you how to create some useful custom tags for the Leaf template engine, written in Swift.

Vapor

How to create a custom Leaf tag?

First we are going to build a really simple tag that can return the path section of the URL based on the incoming request. This comes handy if you have to construct links based on the current path. Creating a custom tag is very easy, we can implement the LeafTag protocol using a struct:

import Vapor
import Leaf

struct PathTag: LeafTag {
    
    static let name = "path"

    func render(_ ctx: LeafContext) throws -> LeafData {
        let value = ctx.request?.url.path ?? ""
        return .string(value)
    }
}

The render method has to return LeafData object, don't worry you can get both the application and the request properties using the input context, and there are static constructors for returning all kind of data types (string, int, bool, etc). I like to have a static name property for all my tags, so I can refer to that one later on when I register my custom tag inside the configuration file. ⚙️

public func configure(_ app: Application) throws {
    //...
    app.views.use(.leaf)
    app.leaf.tags[PathTag.name] = PathTag()
}

We can use the this newly created path tag to construct a URL based on the current path with some additional query parameters like this: #path()?foo=bar.


How to work with custom arguments?

It is possible to define input arguments for a custom leaf tag, you can get the input parameters using the context object. The parameters array contains the list of arguments as LeafData objects, you have to type-cast the data before you could use it as a regular Swift type.

struct CountTag: LeafTag {
    
    static let name = "count"

    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let items = ctx.parameters.first?.array else {
            throw "unable to get parameter key"
        }
        return .int(items.count)
    }
}

// usage
// #count(myArray)

There are multiple helper methods, so you can easily convert the LeafData object to a string, integer, double, array, dictionary, bool, null or you can ship your own data converter.


Useful Leaf tags

I've made a bunch of useful Leaf tags in the last few days, here are some of them.

isEmpty

This is quite similar to the count tag, but it will check if an array is empty or not.

struct IsEmptyTag: LeafTag {
    
    static let name = "isEmpty"

    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let items = ctx.parameters.first?.array else {
            throw "unable to get parameter key"
        }
        return .bool(items.isEmpty)
    }
}
// configuration
app.leaf.tags[IsEmptyTag.name] = IsEmptyTag()

// usage
// #isEmpty(myArray)

I am using this tag to display an empty message when a list has no items.

permalink

Finding right the hostname can be quite problematic, that's why I prefer to have a static baseUrl property on the Application object itself. I can use this constant and the current path to construct the permalink of a given page, it sad that I have to hardcode the base URL, but it is useful. 😅

extension Application {
    // .env.development -> BASE_URL="https://localhost"
    static let baseUrl = Environment.get("BASE_URL")!
}

struct PermalinkTag: LeafTag {
    
    static let name = "permalink"

    func render(_ ctx: LeafContext) throws -> LeafData {
        var absPath = Application.baseUrl
        if let path = ctx.request?.url.path {
            absPath += path
        }
        return .string(absPath)
    }
}

// configuration
app.leaf.tags[PermalinkTag.name] = PermalinkTag()

// usage
// #permalink()

You can use this to construct social media related meta tags where the relative path would not be enough. If you have a better solution for figuring out the hostname please tell me.

parameter

This comes handy if you have a named parameter in your path. This tag will return the value as a string based on the parameter name or null if there is no such parameter.

struct ParameterTag: LeafTag {
    
    static let name = "param"

    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let key = ctx.parameters.first?.string else {
            throw "unable to get parameter key"
        }
        if let value = ctx.request?.parameters.get(key) {
            return .string(value)
        }
        return .null
    }
}
// configuration
app.leaf.tags[ParameterTag.name] = ParameterTag()

// usage
// #parameter("id")

If you have a /todo/:id route, you can get the identifier in the Leaf file by using this tag.

query

This is very similar to the parameter tag, it does the exact same thing, but it works with the query container, so you can return query parameters from the URL.

struct QueryTag: LeafTag {
    
    static let name = "query"

    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let key = ctx.parameters.first?.string else {
            throw "unable to get parameter key"
        }
        if let value: String = ctx.request?.query[key] {
            return .string(value)
        }
        return .null
    }
}
// configuration
app.leaf.tags[QueryTag.name] = QueryTag()

// usage
// #query("search")

In other words if you have a link like this: /?search=foo, the snippet above will return foo.

setQuery

The set query tag can replace one or more query parameters and returns the URL with the path and all the updated query values. You can use this to update offsets, limits, and many more...

struct SetQueryTag: LeafTag {
    
    static let name = "setQuery"

    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let input = ctx.parameters.first?.string else {
            throw "unable to get parameter key"
        }

        let path = ctx.request?.url.path ?? ""
        let query = ctx.request?.url.query ?? ""
        
        var queryItems: [String: String] = [:]
        for item in query.split(separator: "&") {
            let array = item.split(separator: "=")
            guard array.count == 2 else {
                continue
            }
            let k = String(array[0])
            let v = String(array[1])
            queryItems[k] = v
        }
        
        if ctx.parameters.count > 1, let value = ctx.parameters[1].string {
            queryItems[input] = value
        }
        else {
            for newItem in input.split(separator: ",") {
                let array = newItem.split(separator: ":")
                guard array.count == 2 else {
                    continue
                }
                let k = String(array[0])
                let v = String(array[1])
                queryItems[k] = v
            }
        }

        let queryString = queryItems.map { $0 + "=" + $1 }.joined(separator: "&")
        return .string("\(path)?\(queryString)")
    }
}
// configuration
app.leaf.tags[SetQueryTag.name] = SetQueryTag()

// usage
// #setQuery("offset", 1)
// #setQuery("search", "bar")
// #setQuery("offset:1,limit:2")

Unfortunately, I was not able to pass around a dictionary value, that's why I'm splitting & joining strings in the implementation. I know, I know, I don't give a damn about URL encoded values, plus the render function could be a lot better, but you know, it just works for now... 😬


Conclusion

I was a little bit afraid of Leaf 4, because of the new syntax, but honestly I really love the changes. Being able to compose templates using the new template extension logic and the custom tag API are amazing. If you are new to Leaf, I highly recommend to read my beginner's guide article, we still have to wait until the official Vapor documentation arrives, but I think Leaf matured a lot. 🍃

Share this article on Twitter.
Thank you. 🙏

Picture of Tibor Bödecs

Tibor Bödecs

Creator of https://theswiftdev.com (weekly Swift articles), server side Swift enthusiast, full-time dad. -- Follow me & feel free to say hi. 🤘🏻 -- #iOSDev #SwiftLang

Twitter · GitHub


📬

100% Swift news, delivered right into your mailbox

Subscribe to my monthly newsletter. On the first Monday of every month, you'll get an update about the most important Swift community news, including my articles.