Skip to content

Instantly share code, notes, and snippets.

@urraka
Last active July 5, 2021 13:40
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save urraka/ccd1812570ca4b278b9f to your computer and use it in GitHub Desktop.
Save urraka/ccd1812570ca4b278b9f to your computer and use it in GitHub Desktop.
Twig extension for correct indentation of the output.
<?php
require_once 'vendor/autoload.php'; // or however you load Twig
require_once 'Lexer.php';
require_once 'Indent.php';
$twig = new Twig_Environment(new Twig_Loader_Filesystem('templates'));
$twig->addExtension(new Indent_Twig_Extension());
$twig->setLexer(new Indent_Twig_Lexer($twig));
/*
* By default it will use a tab character for indentation.
* This can be overriden, for example using 2 spaces:
*
* $twig->addExtension(new Indent_Twig_Extension(' '));
*
* Example of what it does:
*
* Given variable = "line1\nline2\nline3"
*
* <body>
* {% if true %}
* <stuff>{% if true %}only blocks that take the whole line have effect{% endif %}</stuff>
* {{ variable }}
* {% endif %}
* </body>
*
* will turn into:
*
* <body>
* <stuff>only blocks that take the whole line have effect</stuff>
* line1
* line2
* line3
* </body>
*
* Internally, text tokens surrounding "single line blocks" {% %} are modified to remove preceding
* whitespace and the following endline. I call "single line blocks" those which are only preceded
* by whitespace and are immediately followed by a newline.
*
* The indentation is fixed injecting some filter tags. This illustrates how it works internally:
*
* <body>
* {% if true %}
* {% filter unindent(1) %}
* <stuff>{% if true %}only blocks that take the whole line have effect{% endif %}</stuff>
* {% filter indent(2) %}{{ variable }}{% endfilter %}
* {% endfilter %}
* {% endif %}
* </body>
*
* The indent filter will skip the first line by default and is applied to any single line expression
* blocks {{ }}, passing the amount of preceding indentation as a parameter. It will also remove
* the last remaining empty line if present in the filtered content, because in this case the following
* endline is preserved.
*/
<?php
class Indent_Twig_Extension extends Twig_Extension
{
protected $indent_string;
protected $start_tags;
protected $end_tags;
public function __construct($indent_string = "\t", $options = array())
{
$this->indent_string = $indent_string;
$this->start_tags = array(
'autoescape',
'block',
'embed',
'filter',
'for',
'if',
'macro',
'sandbox',
'spaceless',
'verbatim',
'else',
'elseif'
);
$this->end_tags = array(
'endautoescape',
'endblock',
'endembed',
'endfilter',
'endfor',
'endif',
'endmacro',
'endsandbox',
'endspaceless',
'endverbatim',
'else',
'elseif'
);
if (isset($options['start_tags']))
$this->start_tags = array_merge($this->start_tags, $options['start_tags']);
if (isset($options['end_tags']))
$this->end_tags = array_merge($this->end_tags, $options['end_tags']);
}
public function getName()
{
return 'indent';
}
public function getIndentString()
{
return $this->indent_string;
}
public function getStartTags()
{
return $this->start_tags;
}
public function getEndTags()
{
return $this->end_tags;
}
public function getFilters()
{
$ch = $this->indent_string;
return array(
new Twig_SimpleFilter(
'indent',
function($str, $n, $skip_first = true) use($ch)
{
$prefix = str_repeat($ch, $n);
$lines = explode("\n", $str);
if ($skip_first)
{
$first = array_shift($lines);
if (end($lines) === '')
array_pop($lines);
}
$lines = array_map(function($s) use($prefix) { return $prefix . $s; }, $lines);
if ($skip_first)
array_unshift($lines, $first);
return implode("\n", $lines);
}
),
new Twig_SimpleFilter(
'unindent',
function($str, $n) use($ch)
{
$prefix = str_repeat($ch, $n);
$len = strlen($prefix);
$lines = explode("\n", $str);
$lines = array_map(
function($s) use($prefix, $len) {
return strncmp($s, $prefix, $len) === 0 ? substr($s, $len) : $s;
},
$lines
);
return implode("\n", $lines);
}
)
);
}
}
<?php
class Indent_Twig_Lexer extends Twig_Lexer
{
public function __construct(Twig_Environment $env, array $options = array())
{
parent::__construct($env, $options);
$this->regexes['lex_block'] = str_replace('\\n?', '', $this->regexes['lex_block']);
$this->regexes['lex_comment'] = str_replace('\\n?', '', $this->regexes['lex_comment']);
}
public function tokenize($code, $filename = null)
{
parent::tokenize($code, $filename);
// fetch indent extension parameters
$indent = $this->env->getExtension('indent');
$indent_str = $indent->getIndentString();
$indent_len = strlen($indent_str);
$start_tags = $indent->getStartTags();
$end_tags = $indent->getEndTags();
// find block pairs {% %} and {{ }}
$pairs = array();
$search_type = null;
for ($i = 0, $n = count($this->tokens); $i < $n; $i++)
{
$type = $this->tokens[$i]->getType();
if ($type === $search_type)
{
$pairs[] = $i;
$search_type = null;
}
else if ($type === Twig_Token::BLOCK_START_TYPE)
{
$pairs[] = $i;
$search_type = Twig_Token::BLOCK_END_TYPE;
}
else if ($type === Twig_Token::VAR_START_TYPE)
{
$pairs[] = $i;
$search_type = Twig_Token::VAR_END_TYPE;
}
}
// find single line pairs, fill $substr for text token modifications, and fill $insert
// for indent/unindent filter injection
$insert = array();
$substr = array();
$nsubstr = 0;
$texttype = Twig_Token::TEXT_TYPE;
$texttoken = new Twig_Token($texttype, "\n", 0);
$INDENT = 0;
$UNINDENT = 1;
$START = 0;
$END = 1;
for ($i = 0, $n = count($pairs); $i < $n; $i += 2)
{
$beg = $pairs[$i + 0];
$end = $pairs[$i + 1];
$iprev = $beg - 1;
$inext = $end + 1;
$prev = $iprev >= 0 ? $this->tokens[$iprev] : $texttoken;
$next = $this->tokens[$inext];
if ($prev->getType() === $texttype && $next->getType() === $texttype)
{
$prev_value = $prev->getValue();
$next_value = $next->getValue();
$nl = strrpos($prev_value, "\n");
if ($nl === false && $iprev === 0)
$nl = -1;
if ($nl !== false && $next_value[0] === "\n")
{
$count = strlen($prev_value) - $nl - 1;
if (strspn($prev_value, " \t", $nl + 1) === $count)
{
// the pair is in single line
if ($this->tokens[$beg]->getType() === Twig_Token::BLOCK_START_TYPE)
{
// substr
if ($nsubstr > 0 && $substr[$nsubstr - 3] === $iprev)
{
$substr[$nsubstr - 1] = $count;
}
else if ($iprev >= 0)
{
$substr[] = $iprev;
$substr[] = 0;
$substr[] = $count;
$nsubstr += 3;
}
$substr[] = $inext;
$substr[] = 1;
$substr[] = 0;
$nsubstr += 3;
// unindent injection
$tag = $this->tokens[$beg + 1]->getValue();
if (in_array($tag, $end_tags))
{
$insert[] = $beg;
$insert[] = $END;
$insert[] = $UNINDENT;
}
if (in_array($tag, $start_tags))
{
$insert[] = $end + 1;
$insert[] = $START;
$insert[] = $UNINDENT;
}
}
else
{
// indent injection
$nindent = (int)($count / $indent_len);
if ($nindent > 0)
{
$insert[] = $beg;
$insert[] = $START;
$insert[] = $INDENT;
$insert[] = $nindent;
$insert[] = $end + 1;
$insert[] = $END;
$insert[] = $INDENT;
}
}
}
}
}
}
// consume $substr
for ($i = 0; $i < $nsubstr; $i += 3)
{
$index = $substr[$i];
$start = $substr[$i + 1];
$length = $substr[$i + 2];
$token = $this->tokens[$index];
$value = $token->getValue();
if ($length > 0)
$value = substr($value, $start, -$length);
else
$value = substr($value, $start);
if ($value !== '')
$this->tokens[$index] = new Twig_Token($texttype, $value, $token->getLine());
else
$this->tokens[$index] = null;
}
// fill the final $tokens array while consuming $insert
$tokens = array();
$t = 0;
for ($i = 0, $n = count($insert); $i < $n; $i += 3)
{
$index = $insert[$i];
$type = $insert[$i + 1];
$filter_func = $insert[$i + 2];
while ($t < $index)
{
$token = $this->tokens[$t++];
if ($token !== null)
$tokens[] = $token;
}
if ($type === $START)
{
$line = $this->tokens[$t + ($filter_func === $INDENT ? 0 : -1)]->getLine();
$func = ($filter_func === $INDENT ? 'indent' : 'unindent');
$count = 1;
if ($filter_func === $INDENT)
$count = $insert[($i++) + 3];
array_push(
$tokens,
new Twig_Token(Twig_Token::BLOCK_START_TYPE, '', $line),
new Twig_Token(Twig_Token::NAME_TYPE, 'filter', $line),
new Twig_Token(Twig_Token::NAME_TYPE, $func, $line),
new Twig_Token(Twig_Token::PUNCTUATION_TYPE, '(', $line),
new Twig_Token(Twig_Token::NUMBER_TYPE, $count, $line),
new Twig_Token(Twig_Token::PUNCTUATION_TYPE, ')', $line),
new Twig_Token(Twig_Token::BLOCK_END_TYPE, '', $line)
);
}
else
{
$line = $this->tokens[$t + ($filter_func === $INDENT ? -1 : 0)]->getLine();
$tokens[] = new Twig_Token(Twig_Token::BLOCK_START_TYPE, '', $line);
$tokens[] = new Twig_Token(Twig_Token::NAME_TYPE, 'endfilter', $line);
$tokens[] = new Twig_Token(Twig_Token::BLOCK_END_TYPE, '', $line);
}
}
$n = count($this->tokens);
while ($t < $n)
{
$token = $this->tokens[$t++];
if ($token !== null)
$tokens[] = $token;
}
$this->tokens = $tokens;
return new Twig_TokenStream($this->tokens, $this->filename);
}
}
@PhillippOhlandt
Copy link

Hey @urraka,

I have some problems with this extension.

Here is the twig code I render (for testing):

{% block content %}
  {% setcontent entries = 'entries/latest/5' %}
  {% for entry in entries %}
    {{ entry.id }} {{ entry.title }}
  {% endfor %}
{% endblock %}

It's in the scope of the https://bolt.cm CMS (that's where the setcontent comes from).

I get the following error:
Unexpected "endblock" tag (expecting closing tag for the "filter" tag defined near line 3) in "index.twig" at line 6.

Do you have any ideas why could be the issues or what do I have to tweak in order to get it working?

Thanks,
Phillipp

@prohde
Copy link

prohde commented Mar 25, 2019

How to use this with Twig2?
I get the following errors on trying to set the lexer:
PHP Notice: Undefined property: Indent_Twig_Lexer::$regexes in /var/www/includes/twig/lexer.php on line 7
PHP Notice: Undefined index: lex_comment in /var/www/includes/twig/lexer.php on line 8

Edit:
This will no longer work. For the current source of Twig all members are set to private.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment