Skip to main content

Custom Tags

Liquid tags are defined by a pair of JavaScript objects. One implementing the Tag interface and one implementing the Node interface. The parse method of a Tag is responsible for parsing a tag's expression and returning a Node, which will be added to a template's parse tree.

You can add to, remove or modify Liquid's built-in tags to suit your needs by registering tags with an Environment, then rendering your templates from that environment.

info

All built-in tags are implemented this way, so have a look in src/builtin/tags/ for more examples.

Add a Tag

Taking inspiration from Django's Template Language, lets implement a with tag, which extends the local scope for the duration of its block. Our with tag's expressions consists of one or more named arguments separated by commas. Each argument is a variable name, followed by a colon, then a Liquid keyword, string, integer, float, range expression or identifier.

{% with <identifier>: <object> [, <identifier>: object ... ] %}
<literal,statement,tag> ...
{% endwith %}

This implementation keeps any variables set inside the with block (using assign or capture) alive after the block has been rendered.

info

We use class syntax here for both JavaScript and TypeScript examples. Constructor functions and objects with function valued properties would work equally as well.

Example Tag

A Tag is free to parse its expression any way it chooses. Built in tags use regular expressions to generate a stream of tokens, then step through those tokens yielding Expression objects.

info

Despite their names, ExpressionTokenStream, TokenStream and RenderStream do not implement a Node.js stream or a web stream.

Here we'll reuse the tokenizer from the include tag, as it, too, accepts any number of comma separated named arguments. We will, however, supply a different set of allowed keywords to the tokenizer function.

with_tag.js
import { expressions, tokens } from "liquidscript";

// Reuse the `include` tokenizer.
const tokenize = expressions.include.makeTokenizer(
expressions.include.RE,
new Set([
expressions.TOKEN_TRUE,
expressions.TOKEN_FALSE,
expressions.TOKEN_NIL,
expressions.TOKEN_NULL,
])
);

// Indicates the end of a `with` block.
const TAG_ENDWITH = "endwith";
const END_WITH_BLOCK = new Set([TAG_ENDWITH]);

// ...

The parse() method of a Tag object receives a TokenStream and a reference to the active Environment. This stream of tokens includes template literals, output statements, tags and unparsed tag expressions.

The current token in the stream will always be of the type TOKEN_TAG, representing the start of the tag we're parsing. By convention, this token is used to populate the token property of the associated Node object. If the tag has an expression (anything after the tag's name), it will immediately follow the TOKEN_TAG in the stream as a TOKEN_EXPRESSION. In the example bellow we use TokenStream.expect() to confirm that an expression was provided.

We use parseBlock() from the active environment to parse our with tag's block, which could contain any number of other tags and output statements. Every block tag is expected to leave the stream with it's "end" tag as the current token.

Note that parseExpression and parseArgument are an implementation detail and not part of the Tag interface.

with_tag.js (continued)
class WithTag {
parse(stream, environment) {
const token = stream.next();
stream.expect(tokens.TOKEN_EXPRESSION);
const args = this.parseExpression(stream.current);
stream.next();
const block = environment.parser.parseBlock(stream, END_WITH_BLOCK, token);
stream.expectTag(TAG_ENDWITH);
return new WithNode(token, args, block);
}

parseExpression(expressionToken) {
const args = {};
const eStream = new expressions.ExpressionTokenStream(
tokenize(expressionToken.value, expressionToken.index)
);

while (eStream.current.kind !== tokens.TOKEN_EOF) {
const [key, expr] = this.parseArgument(eStream);
args[key] = expr;
// Eat comma.
if (eStream.current.kind === expressions.TOKEN_COMMA) eStream.next();
}

return args;
}

parseArgument(eStream) {
const key = expressions.parseUnchainedIdentifier(eStream).toString();
eStream.next();
eStream.expect(expressions.TOKEN_COLON);
eStream.next(); // Eat colon
const val = expressions.filtered.parseObject(eStream);
eStream.next();
return [key, val];
}
}

// ...

Example Node

Every Node is required to implement synchronous and asynchronous versions of a render method. By referencing its Expressions and the active render context, the render method is responsible for writing text to the output stream.

Our WithNode simply evaluates each of its arguments and uses the results to extend the scope of the active render context before rendering its block.

with_tag.js (continued)
class WithNode {
constructor(token, args, block) {
this.token = token;
this.args = args;
this.block = block;
}

async render(context, out) {
const scope = {};
for (const [key, value] of Object.entries(this.args)) {
scope[key] = await value.evaluate(context);
}
await context.extend(scope, () => this.block.render(context, out));
}

renderSync(context, out) {
const scope = Object.fromEntries(
Object.entries(this.args).map(([key, value]) => [
key,
value.evaluateSync(context),
])
);
context.extendSync(scope, () => this.block.renderSync(context, out));
}

children() {
return [{ node: this.block }];
}
}

Example Tag Usage

If the above tag implementation is exported from a module called with_tag.js (or ts), we can import and register the tag with a LiquidScript Environment, then use {% with %} in templates rendered from that environment.

The first argument of addTag() is the tag's name, as used by template authors. The second argument is our object implementing the Tag interface.

import { Environment } from "liquidscript";
import { WithTag } from "./with_tag";

const env = new Environment();
env.addTag("with", new WithTag());

const template = env.fromString(`
{% with greeting: 'Hello', name: 'Sally' %}
{{ greeting }}, {{ name }}!
{% endwith %}
`);

console.log(template.renderSync());
// Hello, Sally!

Replace a Tag

If given the name of an existing tag, Environment.addTag() will replace it without warning. For example, the extra "if not" tag, which adds support for negating expressions with not and grouping terms with parentheses, is a drop-in replacement for the standard if tag.

import { Environment, extra } from "liquidscript";

const env = new Environment();
env.addTag("if", new extra.tags.IfNotTag());

Remove a Tag

Remove a tag by deleting it from Environment.tags. It's a plain object mapping tag names to Tag objects. This example removes the little known ifchanged tag, making it unavailable to templates rendered from the environment.

import { Environment } from "liquidscript";

const env = new Environment();
delete env.tags.ifchanged;

Jekyll Style Include Example

This example implements an {% include %} tag, as found in Jekyll. Unlike the standard include tag, this implementation expects either the name of the template without quotes (my_template.liquid), or a fully formed output statement ({{ some_variable }}) that resolves to a string. Additionally, key/value arguments should be separated by = rather than :, and those arguments are put into an include namespace rather than merging them into the existing scope.

note

A custom template loader would also be needed to mimic Jekyll's folder structure.

jekyll_include_tag.mjs
import { StringLiteral, expressions, tokens } from "liquidscript";

const RE_VARIABLE_SYNTAX =
/^\s*\{\{\s*(?<stmt>[\w\-.]+\s*(?:\|.*)?)\}\}\s*(?<args>.*)$/ds;

class JekyllIncludeTag {
parse(stream) {
const token = stream.next();
stream.expect(tokens.TOKEN_EXPRESSION);

// An expression that evaluates to the template name
let templateNameExpression;
// An expression token stream including argument tokens only.
let argStream;

const match = stream.current.value.match(RE_VARIABLE_SYNTAX);
if (match) {
templateNameExpression = expressions.filtered.parse(match.groups.stmt);
argStream = new expressions.ExpressionTokenStream(
expressions.arguments.tokenize(
match.groups.args,
stream.current.index + match.indices.groups.args[0]
)
);
} else {
const [name, args] = stream.current.value.trim().split(/\s(.*)/s);
templateNameExpression = new StringLiteral(name);
argStream = new expressions.ExpressionTokenStream(
expressions.arguments.tokenize(args, stream.current.index + name.length)
);
}

// Key/value pairs, separated by commas, with `=` between each key and
// it's corresponding value.
const args = expressions.arguments.parseArguments(
argStream,
expressions.TOKEN_ASSIGN
);

return new JekyllIncludeNode(token, templateNameExpression, args);
}
}

class JekyllIncludeNode {
constructor(token, templateName, args) {
this.token = token;
this.templateName = templateName;
this.args = args;
}

async render(context, out) {
// Resolve the template name. It could be a variable that resolves to a
// string or a string literal.
const templateName = await this.templateName.evaluate(context);

// Load the template. We tag it with "include" so the template loader
// knows what tag is asking for a template.
const template = await context.getTemplate(templateName, {
tag: "include",
});

// Arguments go into an `include` namespace.
const includeScope = {};
for (const [key, value] of Object.entries(this.args)) {
includeScope[key] = await value.evaluate(context);
}

await context.extend({ include: includeScope }, async () => {
await template.renderWithContext(context, out, false, true);
});
}

renderSync(context, out) {
const templateName = this.templateName.evaluateSync(context);
const template = context.getTemplateSync(templateName, { tag: "include" });

const includeScope = {};
for (const [key, value] of Object.entries(this.args)) {
includeScope[key] = value.evaluateSync(context);
}

context.extendSync({ include: includeScope }, () => {
template.renderWithContextSync(context, out, false, true);
});
}
}