Extensions

Build custom template tags with the Extension plugin architecture

4 min read 740 words

Extensions let you add custom template tags to Kida without modifying core code. An extension registers new block keywords, parses them into AST nodes, and compiles those nodes into Python statements.

Extension Base Class

Subclasskida.extensions.Extensionand declare your tag keywords, node types, and optional end keywords:

from kida.extensions import Extension

class MyExtension(Extension):
    tags = {"mytag"}               # Block keywords this extension handles
    node_types = {"MyTagNode"}     # Node class names this extension compiles
    end_keywords = {"endmytag"}    # End keywords for tags with bodies (optional)

Every extension receives the Environmentinstance on initialization and can provide filters, tests, and globals alongside custom tags:

class MyExtension(Extension):
    tags = {"mytag"}
    node_types = {"MyTagNode"}

    def get_filters(self):
        return {"myfilter": lambda v: v.upper()}

    def get_tests(self):
        return {"even": lambda v: v % 2 == 0}

    def get_globals(self):
        return {"MY_CONST": 42}

Registering Extensions

Pass extension classes (not instances) to theEnvironment:

from kida import Environment

env = Environment(extensions=[MyExtension])

On initialization, the environment:

  1. Instantiates each extension class with itself (ext = ExtClass(self))
  2. Registersget_filters(), get_tests(), and get_globals()results
  3. Maps each tag intagsto the extension instance (parser dispatch)
  4. Maps each name innode_typesto the extension instance (compiler dispatch)
  5. Mergesend_keywordsinto the parser's end-keyword set

Custom Node Types

Define your AST node as a frozen dataclass inheriting fromNode:

from dataclasses import dataclass
from kida.nodes.base import Node

@dataclass(frozen=True, slots=True)
class MyTagNode(Node):
    name: str
    body: tuple  # child nodes

The Node base class provides lineno and col_offset fields for source mapping. Use frozen=True and slots=Truefor immutable, memory-efficient nodes.

Compiler Dispatch

The compiler resolves nodes by type name. When it encounters a node whose class name matches anode_types entry, it calls that extension's compile()method:

Compiler._compile_node(node)
  -> look up type(node).__name__ in _extension_compilers
  -> ext.compile(compiler, node)

This is an O(1) dictionary lookup. The extension'scompile() method must return a list[ast.stmt]of Python AST statements.

End Keywords

If your custom tag wraps a body (like{% mytag %}...{% endmytag %}), declare the closing keyword in end_keywordsso the parser recognizes it:

class MyExtension(Extension):
    tags = {"mytag"}
    node_types = {"MyTagNode"}
    end_keywords = {"endmytag"}

End keywords are merged into the parser's keyword set at initialization. Without this, the parser will not recognize {% endmytag %}as a valid block terminator.

You can also use the universal{% end %} syntax to close extension tags, but declaring end_keywordsenables the explicit form.

Example

Build a complete{% debug expr %}tag that prints a value during rendering:

import ast
from dataclasses import dataclass

from kida import Environment
from kida.extensions import Extension
from kida.nodes.base import Node


@dataclass(frozen=True, slots=True)
class DebugNode(Node):
    expr: object  # Expr node from parser


class DebugExtension(Extension):
    tags = {"debug"}
    node_types = {"DebugNode"}

    def parse(self, parser, tag_name):
        # Current token is "debug" — advance past it
        token = parser._advance()

        # Parse the expression after the tag name
        expr = parser._parse_expression()

        # Expect the block-end delimiter (%})
        parser._expect(parser.TokenType.BLOCK_END)

        return DebugNode(
            lineno=token.lineno,
            col_offset=token.col_offset,
            expr=expr,
        )

    def compile(self, compiler, node):
        # Compile the Kida expression into a Python AST expression
        expr = compiler._compile_expr(node.expr)

        # Return a print() call statement
        return [
            ast.Expr(
                value=ast.Call(
                    func=ast.Name(id="print", ctx=ast.Load()),
                    args=[expr],
                    keywords=[],
                )
            )
        ]


# Register and use
env = Environment(extensions=[DebugExtension])

Template usage:

{% debug user.name %}
{# Prints the value of user.name to stdout during rendering #}

Extension with a Body

For tags that wrap content, declareend_keywords and use _parse_body():

@dataclass(frozen=True, slots=True)
class TimedNode(Node):
    label: object
    body: tuple


class TimedExtension(Extension):
    tags = {"timed"}
    node_types = {"TimedNode"}
    end_keywords = {"endtimed"}

    def parse(self, parser, tag_name):
        token = parser._advance()
        label = parser._parse_expression()
        parser._expect(parser.TokenType.BLOCK_END)
        body = parser._parse_body()

        # Consume {% end %} or {% endtimed %}
        parser._advance()  # {%
        parser._advance()  # end/endtimed
        parser._expect(parser.TokenType.BLOCK_END)

        return TimedNode(
            lineno=token.lineno,
            col_offset=token.col_offset,
            label=label,
            body=tuple(body),
        )

    def compile(self, compiler, node):
        # Compile body nodes
        body_stmts = []
        for child in node.body:
            body_stmts.extend(compiler._compile_node(child))

        label_expr = compiler._compile_expr(node.label)

        # Wrap body in timing instrumentation
        return [
            ast.Expr(value=ast.Call(
                func=ast.Name(id="print", ctx=ast.Load()),
                args=[ast.JoinedStr(values=[
                    ast.Constant(value="[timed] start: "),
                    ast.FormattedValue(value=label_expr, conversion=-1),
                ])],
                keywords=[],
            )),
            *body_stmts,
            ast.Expr(value=ast.Call(
                func=ast.Name(id="print", ctx=ast.Load()),
                args=[ast.JoinedStr(values=[
                    ast.Constant(value="[timed] end: "),
                    ast.FormattedValue(value=label_expr, conversion=-1),
                ])],
                keywords=[],
            )),
        ]

Template usage:

{% timed "sidebar" %}
    {% include "partials/sidebar.html" %}
{% end %}

See Also