Skip to main content

Custom tags

Liquid tags are implemented through the Tag interface.

interface Tag {
parse(token: Token, parser: Parser): Markup;
}

In practice we usually implement Tag as a static method on the class implementing Markup.

info

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

Example tag

This example implements the with tag, which allows template authors to define block scoped variables. {% with %} is a block tag. It has a start tag, an end tag ({% endwith %}), and Liquid markup in between.

{% with <identifier>: <object> [, <identifier>: <object> ... ] %}
<text|markup> ...
{% endwith %}

Template text, markup and expressions are exposed as a single token stream, accessed through a Parser. A tag's static parse method is responsible for asserting and consuming markup delimiters and whitespace control.

Tag implementation

import {
type Block,
Environment,
expression,
type Markup,
type Namespace,
type OutputBuffer,
Parser,
renderBlock,
renderBlockSync,
RenderContext,
T,
type Token,
} from "liquidscript";

const END_WITH_BLOCK = new Set(["endwith"]);

export class WithTag implements Markup {
readonly blank = false;
readonly tag = "with";

constructor(
readonly token: Token,
readonly args: expression.KeywordArgument[],
readonly block: Block,
) {}

static parse(token: Token, parser: Parser): WithTag {
if (parser.kind() === T.COMMA) {
// Leading commas are OK.
parser.next();
}

const args = parser.parseKeywordArguments(true);

if (parser.kind() === T.COMMA) {
// Trailing commas are OK.
parser.next();
}

parser.carryWhitespaceControl();
parser.eat(T.TAG_END);
const block = parser.parseBlock(END_WITH_BLOCK);
parser.eatEmptyTag("endwith");
return new WithTag(token, args, block);
}

async render(context: RenderContext, buffer: OutputBuffer): Promise<void> {
const namespace: Namespace = Object.create(null);

for (const arg of this.args) {
namespace[arg.name.value] = await arg.expr.evaluate(context);
}

await context.extend(namespace, async () => {
await renderBlock(this.block, context, buffer);
});
}

renderSync(context: RenderContext, buffer: OutputBuffer): void {
const namespace: Namespace = Object.create(null);

for (const arg of this.args) {
namespace[arg.name.value] = arg.expr.evaluateSync(context);
}

context.extendSync(namespace, () => {
renderBlockSync(this.block, context, buffer);
});
}
}

Usage

Once we have our tag implementation, we need to register it with a Liquid environment.

// ... continued from above
const env = new Environment();
env.tags["with"] = WithTag;