Kris Wallsmith

Symfony Guru at opensky.com.
Discussing web development, Symfony and fatherhood.

Jan 6

Twig Node Visitors (Part 2)

This is the second in a series of articles on Twig node visitors. Please read part one first.

Node visitors can be used for any number of things. The Twig_NodeVisitorInterface interface itself is just three methods:

interface Twig_NodeVisitorInterface
{
    /**
     * @return Twig_NodeInterface The modified node
     */
    function enterNode(Twig_NodeInterface $node, Twig_Environment $env);

    /**
     * @return Twig_NodeInterface The modified node
     */
    function leaveNode(Twig_NodeInterface $node, Twig_Environment $env);

    /**
     * @return integer The priority level
     */
    function getPriority();
}

The interface is simple and powerful. It provides a mechanism for manipulating nodes before a template is compiled down to a PHP class. It puts no constraints on what it can be used for. In the Twig core there are node visitors for escaping and optimization, both of which bear no semblance to what we are going to do here.

Our use case at OpenSky has to do with querying blocks of CMS content from the database eagerly, based on what blocks have been included in the template using the following function:

{{ cms_block('header') }}

We can accomplish this eager loading by using a node visitor to statically analyze each template and stashing the CMS blocks it calls for. The first method of the interface, enterNode(), can be used to look at every node in each template to see if it represents a call to this function.

public function enterNode(Twig_NodeInterface $node, Twig_Environment $env)
{
    if ($cmsBlock = $this->getCmsBlockKey($node)) {
        $this->cmsBlocks[] = $cmsBlock;
    }

    return $node;
}

// ...

private function getCmsBlockKey(Twig_NodeInterface $node)
{
    if ($node instanceof Twig_Node_Expression_Function
        && 'cms_block' == $node->getAttribute('name')) {
        return $node->getNode('arguments')->getNode(0)->getAttribute('value');
    }
}

This code looks at each node to see if it represents a call to the cms_block function and pushes the first argument (the name of the CMS block) to an internal array for use later.

After running this and debugging what was being stacked onto that array we found a few issues. First, the visitor was not smart enough to crawl included or imported templates and look for CMS blocks there. Second, the node visitor was not being reset for each template so by the end of cache warmup all CMS blocks called across the entire application were stacked on that internal array.

Solving the first issue meant adding a way to recursively crawl the graph of each template’s children — a child being a call to either {% include %} or {% import %} in our case. We did this by adding another internal stack:

public function enterNode(Twig_NodeInterface $node, Twig_Environment $env)
{
    if ($cmsBlock = $this->getCmsBlockKey($node)) {
        $this->cmsBlocks[] = $cmsBlock;
    } elseif ($templateName = $this->getIncludedTemplateName($node)) {
        $this->includes[] = $templateName;
    }

    return $node;
}

// ...

private function getIncludedTemplateName(Twig_NodeInterface $node)
{
    if ($node instanceof Twig_Node_Include || $node instanceof Twig_Node_Import) {
        return $node->getNode('expr')->getAttribute('value');
    }
}

We solved the second issue by listening for the root node, an instance of Twig_Node_Module, and clearing the internal stacks when we leave that node:

public function leaveNode(Twig_NodeInterface $node, Twig_Environment $env)
{
    if ($node instanceof Twig_Node_Module) {
        // todo: make these stacks available at runtime

        // reset
        $this->cmsBlocks = array();
        $this->includes  = array();
    }

    return $node;
}

We’re in pretty good shape at this point. We have built a node visitor that collects the information necessary for eagerly querying our CMS for the blocks that each template calls for. Now we just need to make this information available at runtime, when the eager query needs to be executed.

We’ll do this in the next post by wrapping the Twig_Node_Module in our own module node that compiles down the a PHP class with the necessary public methods. Stay tuned!


  1. kriswallsmith posted this