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.
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.
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.
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.
- JavaScript
- TypeScript
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]);
// ...
import {
Environment,
Expression,
Tag,
expressions,
tokens,
Node,
BlockNode,
RenderContext,
RenderStream,
ContextScope,
} 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]);
type Arguments = {
[index: string]: Expression;
};
// ...
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.
- JavaScript
- TypeScript
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];
}
}
// ...
class WithTag implements Tag {
public parse(stream: tokens.TokenStream, environment: Environment): Node {
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);
}
protected parseExpression(expressionToken: tokens.Token): Arguments {
const args: Arguments = {};
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;
}
protected parseArgument(
eStream: expressions.ExpressionTokenStream
): [string, Expression] {
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.
- JavaScript
- TypeScript
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 }];
}
}
class WithNode implements Node {
constructor(
readonly token: tokens.Token,
readonly args: Arguments,
readonly block: BlockNode
) {}
async render(context: RenderContext, out: RenderStream): Promise<void> {
const scope: ContextScope = {};
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: RenderContext, out: RenderStream): void {
const scope: ContextScope = Object.fromEntries(
Object.entries(this.args).map(([key, value]) => [
key,
value.evaluateSync(context),
])
);
context.extendSync(scope, () => this.block.renderSync(context, out));
}
children(): Node[] {
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.
A custom template loader would also be needed to mimic Jekyll's folder structure.
- JavaScript
- TypeScript
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);
});
}
}
import {
ContextScope,
Expression,
expressions,
LiquidTypeError,
Node,
object,
RenderContext,
RenderStream,
StringLiteral,
tokens,
Tag,
} from "liquidscript";
const RE_VARIABLE_SYNTAX =
/^\s*\{\{\s*(?<stmt>[\w\-.]+\s*(?:\|.*)?)\}\}\s*(?<args>.*)$/ds;
/**
* The match object we expect back from our variable syntax regular expression.
*/
interface VariableSyntaxMatch {
groups: { stmt: string; args: string };
indices: { groups: { stmt: number[]; args: number[] } };
}
/**
* A type predicate for the `VariableSyntaxMatch` interface.
*/
function isVariableSyntaxMatch(match: unknown): match is VariableSyntaxMatch {
return match !== null;
}
class JekyllIncludeTag implements Tag {
parse(stream: tokens.TokenStream) {
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 (isVariableSyntaxMatch(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 implements Node {
constructor(
readonly token: tokens.Token,
readonly templateName: Expression,
readonly args: expressions.arguments.Arguments
) {}
async render(context: RenderContext, out: RenderStream) {
// 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);
if (!object.isString(templateName))
throw new LiquidTypeError(
`invalid template name, expected a string, found ${templateName}`,
this.token
);
// 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: ContextScope = {};
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: RenderContext, out: RenderStream) {
const templateName = this.templateName.evaluateSync(context);
if (!object.isString(templateName))
throw new LiquidTypeError(
`invalid template name, expected a string, found ${templateName}`,
this.token
);
const template = context.getTemplateSync(templateName, { tag: "include" });
const includeScope: ContextScope = {};
for (const [key, value] of Object.entries(this.args)) {
includeScope[key] = value.evaluateSync(context);
}
context.extendSync({ include: includeScope }, () => {
template.renderWithContextSync(context, out, false, true);
});
}
}