]> source.dussan.org Git - rspamd.git/commitdiff
[Minor] Add lua-lupa library for Jinja2 templates
authorVsevolod Stakhov <vsevolod@highsecure.ru>
Tue, 26 Mar 2019 12:36:41 +0000 (12:36 +0000)
committerVsevolod Stakhov <vsevolod@highsecure.ru>
Tue, 26 Mar 2019 12:36:41 +0000 (12:36 +0000)
CMakeLists.txt
contrib/lua-lupa/LICENSE [new file with mode: 0644]
contrib/lua-lupa/README.md [new file with mode: 0644]
contrib/lua-lupa/lupa.lua [new file with mode: 0644]

index ecd2440baf8b2613aab8b9454f117f33d62cb958..b0de490dc5a434753254f35a5370731f1f775af7 100644 (file)
@@ -1376,6 +1376,7 @@ ENDFOREACH(LUA_LIB)
 INSTALL(FILES "contrib/lua-fun/fun.lua" DESTINATION ${LUALIBDIR})
 INSTALL(FILES "contrib/lua-argparse/argparse.lua" DESTINATION ${LUALIBDIR})
 INSTALL(FILES "contrib/lua-tableshape/tableshape.lua" DESTINATION ${LUALIBDIR})
+INSTALL(FILES "contrib/lua-lupa/lupa.lua" DESTINATION ${LUALIBDIR})
 
 IF(ENABLE_TORCH MATCHES "ON")
        INSTALL(FILES "contrib/lua-moses/moses.lua" DESTINATION ${LUALIBDIR})
diff --git a/contrib/lua-lupa/LICENSE b/contrib/lua-lupa/LICENSE
new file mode 100644 (file)
index 0000000..66c1141
--- /dev/null
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2015-2018 Mitchell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/contrib/lua-lupa/README.md b/contrib/lua-lupa/README.md
new file mode 100644 (file)
index 0000000..edf6dce
--- /dev/null
@@ -0,0 +1,179 @@
+# Lupa
+
+## Introduction
+
+Lupa is a [Jinja2][] template engine implementation written in Lua and supports
+Lua syntax within tags and variables.
+
+Lupa was sponsored by the [Library of the University of Antwerp][].
+
+[Jinja2]: http://jinja.pocoo.org
+[Library of the University of Antwerp]: http://www.uantwerpen.be/
+
+## Requirements
+
+Lupa has the following requirements:
+
+* [Lua][] 5.1, 5.2, or 5.3.
+* The [LPeg][] library.
+
+[Lua]: http://www.lua.org
+[LPeg]: http://www.inf.puc-rio.br/~roberto/lpeg/
+
+## Download
+
+Download Lupa from the project’s [download page][].
+
+[download page]: download
+
+## Installation
+
+Unzip Lupa and place the "lupa.lua" file in your Lua installation's
+`package.path`. This location depends on your version of Lua. Typical locations
+are listed below.
+
+* Lua 5.1: */usr/local/share/lua/5.1/* or */usr/local/share/lua/5.1/*
+* Lua 5.2: */usr/local/share/lua/5.2/* or */usr/local/share/lua/5.2/*
+* Lua 5.3: */usr/local/share/lua/5.3/* or */usr/local/share/lua/5.3/*
+
+You can also place the "lupa.lua" file wherever you'd like and add it to Lua's
+`package.path` manually in your program. For example, if Lupa was placed in a
+*/home/user/lua/* directory, it can be used as follows:
+
+    package.path = package.path..';/home/user/lua/?.lua'
+
+## Usage
+
+Lupa is simply a Lua library. Its `lupa.expand()` and `lupa.expand_file()`
+functions may called to process templates. For example:
+
+    lupa = require('lupa')
+    lupa.expand("hello {{ s }}!", {s = "world"}) --> "hello world!"
+    lupa.expand("{% for i in {1, 2, 3} %}{{ i }}{% endfor %}") --> 123
+
+By default, Lupa loads templates relative to the current working directory. This
+can be changed by reconfiguring Lupa:
+
+    lupa.expand_file('name') --> expands template "./name"
+    lupa.configure{loader = lupa.loaders.filesystem('path/to/templates')}
+    lupa.expand_file('name') --> expands template "path/to/templates/name"
+
+See Lupa's [API documentation][] for more information.
+
+[API documentation]: api.html
+
+## Syntax
+
+Please refer to Jinja2's extensive [template documentation][]. Any
+incompatibilities are listed in the sections below.
+
+[template documentation]: http://jinja.pocoo.org/docs/dev/templates/
+
+## Comparison with Jinja2
+
+While Lua and Python (Jinja2's implementation language) share some similarities,
+the languages themselves are fundamentally different. Nevertheless, a
+significant effort was made to support a vast majority of Jinja2's Python-style
+syntax. As a result, Lupa passes Jinja2's test suite with only a handful of
+modifications. The comprehensive list of differences between Lupa and Jinja2 is
+described in the following sections.
+
+### Fundamental Differences
+
+* Expressions use Lua's syntax instead of Python's, so many of Python's
+  syntactic constructs are not valid. However, the following constructs
+  *are valid*, despite being invalid in pure Lua:
+
+  + Iterating over table literals or table variables directly in a "for" loop:
+
+        {% for i in {1, 2, 3} %}...{% endfor %}
+
+  + Conditional loops via an "if" expression suffix:
+
+        {% for x in range(10) if is_odd(x) %}...{% endfor %}
+
+  + Table unpacking for list elements when iterating through a list of lists:
+
+        {% for a, b, c in {{1, 2, 3}, {4, 5, 6}} %}...{% endfor %}
+
+  + Default values for macro arguments:
+
+        {% macro m(a, b, c='c', d='d') %}...{% endmacro %}
+
+* Strings do not have unicode escapes nor is unicode interpreted in any way.
+
+### Syntactic Differences
+
+* Line statements are not supported due to parsing complexity.
+* In `{% for ... %}` loops, the `loop.length`, `loop.revindex`,
+  `loop.revindex0`, and `loop.last` variables only apply to sequences, where
+  Lua's `'#'` operator applies.
+* The `{% continue %}` and `{% break %}` loop controls are not supported due to
+  complexity.
+* Loops may be used recursively by default, so the `recursive` loop modifier is
+  not supported.
+* The `is` operator is not supported by Lua, so tests of the form `{{ x is y }}`
+  should be written `{{ is_y(x) }}` (e.g. `{{ is_number(42) }}`).
+* Filters cannot occur after tokens within an expression (e.g.
+  `{{ "foo"|upper .. "bar"|upper }}`), but can only occur at the end of an
+  expression (e.g. `{{ "foo".."bar"|upper }}`).
+* Blocks always have access to scoped variables, so the `scoped` block modifier
+  is not supported.
+* Named block end tags are not supported since the parser cannot easily keep
+  track of that state information.
+* Any `{% block ... %}` tags within a "false" block (e.g. `{% if a %}` where `a`
+  evaluates to `false`) are never read and stored due to the parser
+  implementation.
+* Inline "if" expressions (e.g. `{% extends b if a else c %}`) are not
+  supported. Instead, use a Lua conditional expression
+  (e.g. `{% extends a and b or c %}`).
+* Any `{% extends ... %}` tags within a sub-scope are not effective outside that
+  scope (e.g. `{% if a %}{% extends a %}{% else %}{% extends b %}{% endif %}`).
+  Instead, use a Lua conditional expression (e.g. `{% extends a or b %}`).
+* Macros are simply Lua functions and have no metadata attributes.
+* Macros do not have access to a `kwargs` variable since Lua does not support
+  keyword arguments.
+* `{% from x import y %}` tags are not supported. Instead, you must use either
+  `{% import x %}`, which imports all globals in `x` into the current
+  environment, or use `{% import x as z %}`, which imports all globals in `x`
+  into the variable `z`.
+* `{% set ... %}` does not support multiple assignment. Use `{% do ...%}`
+  instead. The catch is that `{% do ... %}` does not support filters.
+* The `{% trans %}` and `{% endtrans %}` tags, `{% with %}` and `{% endwith %}`
+  tags, and `{% autoescape %}` and `{% endautoescape %}` tags are not supported
+  since they are outside the scope of this implementation.
+
+### Filter Differences
+
+* Only the `batch`, `groupby`, and `slice` filters return generators which
+  produce one item at a time when looping. All other filters that produce
+  iterable results generate all items at once.
+* The `float` filter only works in Lua 5.3 since that version of Lua has a
+  distinction between floats and integers.
+* The `safe` filter must appear at the end of a filter chain since its output
+  cannot be passed to any other filter.
+
+### Function Differences
+
+* The global `range(n)` function returns a sequence from 1 to `n`, inclusive,
+  since lists start at 1 in Lua.
+* No `lipsum()`, `dict()`, or `joiner()` functions for the sake of simplicity.
+
+### API Differences
+
+* Lupa has a much simpler API consisting of just four functions and three
+  fields:
+
+  + `lupa.expand()`: Expands a string template subject to an environment.
+  + `lupa.expand_file()`: Expands a file template subject to an environment.
+  + `lupa.configure()` Configures delimiters and template options.
+  + `lupa.reset()`: Resets delimiters and options to their defaults.
+  + `lupa.env`: The default environment for templates.
+  + `lupa.filters`: The set of available filters (`escape`, `join`, etc.).
+  + `lupa.tests`: The set of available tests (`is_odd`, `is_defined`, etc.).
+
+* There is no bytecode caching.
+* Lupa has no extension mechanism. Instead, modify `lupa.env`, `lupa.filters`,
+  and `lupa.tests` directly. However, the parser cannot be extended.
+* Sandboxing is not supported, although `lupa.env` is safe by default (`io`,
+  `os.execute`, `os.remove`, etc. are not available).
diff --git a/contrib/lua-lupa/lupa.lua b/contrib/lua-lupa/lupa.lua
new file mode 100644 (file)
index 0000000..fc49ac2
--- /dev/null
@@ -0,0 +1,1807 @@
+-- Copyright 2015-2019 Mitchell mitchell.att.foicica.com. See LICENSE.
+-- Sponsored by the Library of the University of Antwerp.
+-- Contributions from Ana Balan.
+-- Lupa templating engine.
+
+--[[ This comment is for LuaDoc.
+---
+-- Lupa is a Jinja2 template engine implementation written in Lua and supports
+-- Lua syntax within tags and variables.
+module('lupa')]]
+local M = {}
+
+local lpeg = require('lpeg')
+lpeg.locale(lpeg)
+local space, newline = lpeg.space, lpeg.P('\r')^-1 * '\n'
+local P, S, V = lpeg.P, lpeg.S, lpeg.V
+local C, Cc, Cg, Cp, Ct = lpeg.C, lpeg.Cc, lpeg.Cg, lpeg.Cp, lpeg.Ct
+
+---
+-- Lupa's expression filters.
+-- @class table
+-- @name filters
+M.filters = {}
+
+---
+-- Lupa's value tests.
+-- @class table
+-- @name tests
+M.tests = {}
+
+---
+-- Lupa's template loaders.
+-- @class table
+-- @name loaders
+M.loaders = {}
+
+-- Lua version compatibility.
+if _VERSION == 'Lua 5.1' then
+  function load(ld, source, mode, env)
+    local f, err = loadstring(ld)
+    if f and env then return setfenv(f, env) end
+    return f, err
+  end
+  table.unpack = unpack
+end
+
+local newline_sequence, keep_trailing_newline, autoescape = '\n', false, false
+local loader
+
+-- Creates and returns a token pattern with token name *name* and pattern
+-- *patt*.
+-- The returned pattern captures three values: the token's position and name,
+-- and either a string value or table of capture values.
+-- Tokens are used to construct an Abstract Syntax Tree (AST) for a template.
+-- @param name The name of the token.
+-- @param patt The pattern to match. It must contain only one capture: either a
+--   string or table of captures.
+-- @see evaluate
+local function token(name, patt) return Cp() * Cc(name) * patt end
+
+-- Returns an LPeg pattern that immediately raises an error with message
+-- *errmsg* for invalid syntax when parsing a template.
+-- @param errmsg The error message to raise an error with.
+local function lpeg_error(errmsg)
+  return P(function(input, index)
+    input = input:sub(1, index)
+    local _, line_num = input:gsub('\n', '')
+    local col_num = #input:match('[^\n]*$')
+    error(string.format('Parse Error in file "%s" on line %d, column %d: %s',
+                        M._FILENAME, line_num + 1, col_num, errmsg), 0)
+  end)
+end
+
+---
+-- Configures the basic delimiters and options for templates.
+-- This function then regenerates the grammar for parsing templates.
+-- Note: this function cannot be used iteratively to configure Lupa options.
+-- Any options not provided are reset to their default values.
+-- @param ts The tag start delimiter. The default value is '{%'.
+-- @param te The tag end delimiter. The default value is '%}'.
+-- @param vs The variable start delimiter. The default value is '{{'.
+-- @param ve The variable end delimiter. The default value is '}}'.
+-- @param cs The comment start delimiter. The default value is '{#'.
+-- @param ce The comment end delimiter. The default value is '#}'.
+-- @param options Optional set of options for templates:
+--
+--   * `trim_blocks`: Trim the first newline after blocks.
+--   * `lstrip_blocks`: Strip line-leading whitespace in front of tags.
+--   * `newline_sequence`: The end-of-line character to use.
+--   * `keep_trailing_newline`: Whether or not to keep a newline at the end of
+--     a template.
+--   * `autoescape`: Whether or not to autoescape HTML entities. May be a
+--     function that accepts the template's filename as an argument and returns
+--     a boolean.
+--   * `loader`: Function that receives a template name to load and returns the
+--     path to that template.
+-- @name configure
+function M.configure(ts, te, vs, ve, cs, ce, options)
+  if type(ts) == 'table' then options, ts = ts, nil end
+  if not ts then ts = '{%' end
+  if not te then te = '%}' end
+  if not vs then vs = '{{' end
+  if not ve then ve = '}}' end
+  if not cs then cs = '{#' end
+  if not ce then ce = '#}' end
+
+  -- Tokens for whitespace control.
+  local lstrip = token('lstrip', C('-')) + '+' -- '+' is handled by grammar
+  local rstrip = token('rstrip', -(P(te) + ve + ce) * C('-'))
+
+  -- Configure delimiters, including whitespace control.
+  local tag_start = P(ts) * lstrip^-1 * space^0
+  local tag_end = space^0 * rstrip^-1 * P(te)
+  local variable_start = P(vs) * lstrip^-1 * space^0
+  local variable_end = space^0 * rstrip^-1 * P(ve)
+  local comment_start = P(cs) * lstrip^-1 * space^0
+  local comment_end = space^0 * rstrip^-1 * P(ce)
+  if options and options.trim_blocks then
+    -- Consider whitespace, including a newline, immediately following a tag as
+    -- part of that tag so it is not captured as plain text. Basically, strip
+    -- the trailing newline from tags.
+    tag_end = tag_end * S(' \t')^0 * newline^-1
+    comment_end = comment_end * S(' \t')^0 * newline^-1
+  end
+
+  -- Error messages.
+  local variable_end_error = lpeg_error('"'..ve..'" expected')
+  local comment_end_error = lpeg_error('"'..ce..'" expected')
+  local tag_end_error = lpeg_error('"'..te..'" expected')
+  local endraw_error = lpeg_error('additional tag or "'..ts..' endraw '..te..
+                                  '" expected')
+  local expr_error = lpeg_error('expression expected')
+  local endblock_error = lpeg_error('additional tag or "'..ts..' endblock '..
+                                    te..'" expected')
+  local endfor_error = lpeg_error('additional tag or "'..ts..' endfor '..te..
+                                  '" expected')
+  local endif_error = lpeg_error('additional tag or "'..ts..' endif '..te..
+                                 '" expected')
+  local endmacro_error = lpeg_error('additional tag or "'..ts..' endmacro '..
+                                    te..'" expected')
+  local endcall_error = lpeg_error('additional tag or "'..ts..' endcall '..te..
+                                   '" expected')
+  local endfilter_error = lpeg_error('additional tag or "'..ts..' endfilter '..
+                                     te..'" expected')
+  local tag_error = lpeg_error('unknown or unexpected tag')
+  local main_error = lpeg_error('unexpected character; text or tag expected')
+
+  -- Grammar.
+  M.grammar = Ct(P{
+    -- Utility patterns used by tokens.
+    entity_start = tag_start + variable_start + comment_start,
+    any_text = (1 - V('entity_start'))^1,
+    -- Allow '{{' by default in expression text since it is valid in Lua.
+    expr_text = (1 - tag_end - tag_start - comment_start)^1,
+    -- When `options.lstrip_blocks` is enabled, ignore leading whitespace
+    -- immediately followed by a tag (as long as '+' is not present) so that
+    -- whitespace not captured as plain text. Basically, strip leading spaces
+    -- from tags.
+    line_text = (1 - newline - V('entity_start'))^1,
+    lstrip_entity_start = -P(vs) * (P(ts) + cs) * -P('+'),
+    lstrip_space = S(' \t')^1 * #V('lstrip_entity_start'),
+    text_lines = V('line_text') * (newline * -(S(' \t')^0 * V('lstrip_entity_start')) * V('line_text'))^0 * newline^-1 + newline,
+
+    -- Plain text.
+    text = (not options or not options.lstrip_blocks) and
+           token('text', C(V('any_text'))) or
+           V('lstrip_space') + token('text', C(V('text_lines'))),
+
+    -- Variables: {{ expr }}.
+    lua_table = '{' * ((1 - S('{}')) + V('lua_table'))^0 * '}',
+    variable = variable_start *
+               token('variable', C((V('lua_table') + (1 - variable_end))^0)) *
+               (variable_end + variable_end_error),
+
+    -- Filters: handled in variable evaluation.
+
+    -- Tests: handled in control structure expression evaluation.
+
+    -- Comments: {# comment #}.
+    comment = comment_start * (1 - comment_end)^0 * (comment_end + comment_end_error),
+
+    -- Whitespace control: handled in tag/variable/comment start/end.
+
+    -- Escaping: {% raw %} body {% endraw %}.
+    raw_block = tag_start * 'raw' * (tag_end + tag_end_error) *
+                token('text', C((1 - (tag_start * 'endraw' * tag_end))^0)) *
+                (tag_start * 'endraw' * tag_end + endraw_error),
+
+    -- Note: line statements are not supported since this grammer cannot parse
+    -- Lua itself.
+
+    -- Template inheritence.
+    -- {% block ... %} body {% endblock %}
+    block_block = tag_start * 'block' * space^1 * token('block', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                  V('body')^-1)) *
+                  (tag_start * 'endblock' * tag_end + endblock_error),
+    -- {% extends ... %}
+    extends_tag = tag_start * 'extends' * space^1 * token('extends', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
+    -- Super blocks are handled in variables.
+    -- Note: named block end tags are not supported since keeping track of that
+    -- state information is difficult.
+    -- Note: block nesting and scope is not applicable since blocks always have
+    -- access to scoped variables in this implementation.
+
+    -- Control Structures.
+    -- {% for expr %} body {% else %} body {% endfor %}
+    for_block = tag_start * 'for' * space^1 * token('for', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                V('body')^-1 *
+                Cg(Ct(tag_start * 'else' * tag_end *
+                      V('body')^-1), 'else')^-1)) *
+                (tag_start * 'endfor' * tag_end + endfor_error),
+    -- {% if expr %} body {% elseif expr %} body {% else %} body {% endif %}
+    if_block = tag_start * 'if' * space^1 * token('if', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+               V('body')^-1 *
+               Cg(Ct(Ct(tag_start * 'elseif' * space^1 * (Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                       V('body')^-1)^1), 'elseif')^-1 *
+               Cg(Ct(tag_start * 'else' * tag_end *
+                       V('body')^-1), 'else')^-1)) *
+               (tag_start * 'endif' * tag_end + endif_error),
+    -- {% macro expr %} body {% endmacro %}
+    macro_block = tag_start * 'macro' * space^1 * token('macro', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                  V('body')^-1)) *
+                  (tag_start * 'endmacro' * tag_end + endmacro_error),
+    -- {% call expr %} body {% endcall %}
+    call_block = tag_start * 'call' * (space^1 + #P('(')) * token('call', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                  V('body')^-1)) *
+                  (tag_start * 'endcall' * tag_end + endcall_error),
+    -- {% filter expr %} body {% endfilter %}
+    filter_block = tag_start * 'filter' * space^1 * token('filter', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
+                   V('body')^-1)) *
+                   (tag_start * 'endfilter' * tag_end + endfilter_error),
+    -- {% set ... %}
+    set_tag = tag_start * 'set' * space^1 * token('set', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
+    -- {% include ... %}
+    include_tag = tag_start * 'include' * space^1 * token('include', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
+    -- {% import ... %}
+    import_tag = tag_start * 'import' * space^1 * token('import', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
+
+    -- Note: i18n is not supported since it is out of scope for this
+    -- implementation.
+
+    -- Expression statement: {% do ... %}.
+    do_tag = tag_start * 'do' * space^1 * token('do', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
+
+    -- Note: loop controls are not supported since that would require jumping
+    -- between "scopes" (e.g. from within an "if" block to outside that "if"
+    -- block's parent "for" block when coming across a {% break %} tag).
+
+    -- Note: with statement is not supported since it is out of scope for this
+    -- implementation.
+
+    -- Note: autoescape is not supported since it is out of scope for this
+    -- implementation.
+
+    -- Any valid blocks of text or tags.
+    body = (V('text') + V('variable') + V('comment') + V('raw_block') +
+            V('block_block') + V('extends_tag') + V('for_block') +
+            V('if_block') + V('macro_block') + V('call_block') +
+            V('filter_block') + V('set_tag') + V('include_tag') +
+            V('import_tag') + V('do_tag'))^0,
+
+    -- Main pattern.
+    V('body') * (-1 + tag_start * tag_error + main_error),
+  })
+
+  -- Other options.
+  if options and options.newline_sequence then
+    assert(options.newline_sequence:find('^\r?\n$'),
+           'options.newline_sequence must be "\r\n" or "\n"')
+    newline_sequence = options.newline_sequence
+  else
+    newline_sequence = '\n'
+  end
+  if options and options.keep_trailing_newline then
+    keep_trailing_newline = options.keep_trailing_newline
+  else
+    keep_trailing_newline = false
+  end
+  if options and options.autoescape then
+    autoescape = options.autoescape
+  else
+    autoescape = false
+  end
+  if options and options.loader then
+    assert(type(options.loader) == 'function',
+           'options.loader must be a function that returns a filename')
+    loader = options.loader
+  else
+    loader = M.loaders.filesystem()
+  end
+end
+
+-- Wraps Lua's `assert()` in template environment *env* such that, when called
+-- in conjunction with another Lua function that produces an error message (e.g.
+-- `load()` and `pcall()`), that error message's context (source and line
+-- number) is replaced by the template's context.
+-- This results in Lua's error messages pointing to a template position rather
+-- than this library's source code.
+-- @param env The environment for the currently running template. It must have
+--   a `_SOURCE` field with the template's source text and a `_POSITION` field
+--   with the current position of expansion.
+-- @param ... Arguments to Lua's `assert()`.
+local function env_assert(env, ...)
+  if not select(1, ...) then
+    local input = env._LUPASOURCE:sub(1, env._LUPAPOSITION)
+    local _, line_num = input:gsub('\n', '')
+    local col_num = #input:match('[^\n]*$')
+    local errmsg = select(2, ...)
+    errmsg = errmsg:match(':%d+: (.*)$') or errmsg -- reformat if necessary
+    error(string.format('Runtime Error in file "%s" on line %d, column %d: %s',
+                        env._LUPAFILENAME, line_num + 1, col_num, errmsg), 0)
+  end
+  return ...
+end
+
+-- Returns a generator that returns the position and filter in a list of
+-- filters, taking into account '|'s that may be within filter arguments.
+-- @usage for pos, filter in each_filter('foo|join("|")|bar') do ... end
+local function each_filter(s)
+  local init = 1
+  return function(s)
+    local pos, filter, e = s:match('^%s*()([^|(]+%b()[^|]*)|?()', init)
+    if not pos then pos, filter, e = s:match('()([^|]+)|?()', init) end
+    init = e
+    return pos, filter
+  end, s
+end
+
+-- Evaluates template variable *expression* subject to template environment
+-- *env*, applying any filters given in *expression*.
+-- @param expression The string expression to evaluate.
+-- @param env The environment to evaluate the expression in.
+local function eval(expression, env)
+  local expr, pos, filters = expression:match('^([^|]*)|?()(.-)$')
+  -- Evaluate base expression.
+  local f = env_assert(env, load('return '..expr, nil, nil, env))
+  local result = select(2, env_assert(env, pcall(f)))
+  -- Apply any filters.
+  local results, multiple_results = nil, false
+  local p = env._LUPAPOSITION + pos - 1 -- mark position at first filter
+  for pos, filter in each_filter(filters) do
+    env._LUPAPOSITION = p + pos - 1 -- update position for error messages
+    local name, params = filter:match('^%s*([%w_]+)%(?(.-)%)?%s*$')
+    f = M.filters[name]
+    env_assert(env, f, 'unknown filter "'..name..'"')
+    local args = env_assert(env, load('return {'..params..'}', nil, nil, env),
+                            'invalid filter parameter(s) for "'..name..'"')()
+    if not multiple_results then
+      results = {select(2,
+                        env_assert(env, pcall(f, result, table.unpack(args))))}
+    else
+      for i = 1, #results do table.insert(args, i, results[i]) end
+      results = {select(2, env_assert(env, pcall(f, table.unpack(args))))}
+    end
+    result, multiple_results = results[1], #results > 1
+  end
+  if multiple_results then return table.unpack(results) end
+  return result
+end
+
+local iterate
+
+-- Iterates over *ast*, a collection of tokens from a portion of a template's
+-- Abstract Syntax Tree (AST), evaluating any expressions in template
+-- environment *env*, and returns a concatenation of the results.
+-- @param ast A template's AST or portion of its AST (e.g. portion inside a
+--   'for' control structure).
+-- @param env Environment to evaluate any expressions in.
+local function evaluate(ast, env)
+  local chunks = {}
+  local extends -- text of a parent template
+  local rstrip -- flag for stripping leading whitespace of next token
+  for i = 1, #ast, 3 do
+    local pos, token, block = ast[i], ast[i + 1], ast[i + 2]
+    env._LUPAPOSITION = pos
+    if token == 'text' then
+      chunks[#chunks + 1] = block
+    elseif token == 'variable' then
+      local value = eval(block, env)
+      if autoescape then
+        local escape = autoescape
+        if type(autoescape) == 'function' then
+          escape = autoescape(env._LUPAFILENAME) -- TODO: test
+        end
+        if escape and type(value) == 'string' then
+          value = M.filters.escape(value)
+        end
+      end
+      chunks[#chunks + 1] = value ~= nil and tostring(value) or ''
+    elseif token == 'extends' then
+      env_assert(env, not extends,
+                 'cannot have multiple "extends" in the same scope')
+      local file = eval(block, env) -- covers strings and variables
+      extends = file
+      env._LUPAEXTENDED = true -- used by parent templates
+    elseif token == 'block' then
+      local name = block.expression:match('^[%w_]+$')
+      env_assert(env, name, 'invalid block name')
+      -- Store the block for potential use by the parent template if this
+      -- template is a child template, or for use by `self`.
+      if not env._LUPABLOCKS then env._LUPABLOCKS = {} end
+      if not env._LUPABLOCKS[name] then env._LUPABLOCKS[name] = {} end
+      table.insert(env._LUPABLOCKS[name], 1, block)
+      -- Handle the block properly.
+      if not extends then
+        if not env._LUPAEXTENDED then
+          -- Evaluate the block normally.
+          chunks[#chunks + 1] = evaluate(block, env)
+        else
+          -- A child template is overriding this parent's named block. Evaluate
+          -- the child's block and use it instead of the parent's.
+          local blocks = env._LUPABLOCKS[name]
+          local super_env = setmetatable({super = function()
+            -- Loop through the chain of defined blocks, evaluating from top to
+            -- bottom, and return the bottom block. In each sub-block, the
+            -- 'super' variable needs to point to the next-highest block's
+            -- evaluated result.
+            local super = evaluate(block, env) -- start with parent block
+            local sub_env = setmetatable({super = function() return super end},
+                                         {__index = env})
+            for i = 1, #blocks - 1 do super = evaluate(blocks[i], sub_env) end
+            return super
+          end}, {__index = env})
+          chunks[#chunks + 1] = evaluate(blocks[#blocks], super_env)
+        end
+      end
+    elseif token == 'for' then
+      local expr = block.expression
+      local p = env._LUPAPOSITION -- mark position at beginning of expression
+      -- Extract variable list and generator.
+      local patt = '^([%w_,%s]+)%s+in%s+()(.+)%s+if%s+(.+)$'
+      local var_list, pos, generator, if_expr = expr:match(patt)
+      if not var_list then
+        var_list, pos, generator = expr:match('^([%w_,%s]+)%s+in%s+()(.+)$')
+      end
+      env_assert(env, var_list and generator, 'invalid for expression')
+      -- Store variable names in a list for loop assignment.
+      local variables = {}
+      for variable, pos in var_list:gmatch('([^,%s]+)()') do
+        env._LUPAPOSITION = p + pos - 1 -- update position for error messages
+        env_assert(env, variable:find('^[%a_]') and variable ~= 'loop',
+                   'invalid variable name')
+        variables[#variables + 1] = variable
+      end
+      -- Evaluate the generator and perform the iteration.
+      env._LUPAPOSITION = p + pos - 1 -- update position to generator
+      if not generator:find('|') then
+        generator = env_assert(env, load('return '..generator, nil, nil, env))
+      else
+        local generator_expr = generator
+        generator = function() return eval(generator_expr, env) end
+      end
+      local new_env = setmetatable({}, {__index = env})
+      chunks[#chunks + 1] = iterate(generator, variables, if_expr, block,
+                                    new_env, 1, ast[i + 4] == 'lstrip')
+    elseif token == 'if' then
+      if eval(block.expression, env) then
+        chunks[#chunks + 1] = evaluate(block, env)
+      else
+        local elseifs = block['elseif']
+        if elseifs then
+          for j = 1, #elseifs do
+            if eval(elseifs[j].expression, env) then
+              chunks[#chunks + 1] = evaluate(elseifs[j], env)
+              break
+            end
+          end
+        elseif block['else'] then
+          chunks[#chunks + 1] = evaluate(block['else'], env)
+        end
+      end
+    elseif token == 'macro' then
+      -- Parse the macro's name and parameter list.
+      local signature = block.expression
+      local name, param_list = signature:match('^([%w_]+)(%b())')
+      env_assert(env, name and param_list, 'invalid macro expression')
+      param_list = param_list:sub(2, -2)
+      local p = env._LUPAPOSITION + #name + 1 -- mark pos at beginning of args
+      local params, defaults = {}, {}
+      for param, pos, default in param_list:gmatch('([%w_]+)=?()([^,]*)') do
+        params[#params + 1] = param
+        if default ~= '' then
+          env._LUPAPOSITION = p + pos - 1 -- update position for error messages
+          local f = env_assert(env, load('return '..default))
+          defaults[param] = select(2, env_assert(env, pcall(f)))
+        end
+      end
+      -- Create the function associated with the macro such that when the
+      -- function is called (from within {{ ... }}), the macro's body is
+      -- evaluated subject to an environment where parameter names are variables
+      -- whose values are the ones passed to the macro itself.
+      env[name] = function(...)
+        local new_env = setmetatable({}, {__index = function(_, k)
+          if k == 'caller' and type(env[k]) ~= 'function' then return nil end
+          return env[k]
+        end})
+        local args = {...}
+        -- Assign the given parameter values.
+        for i = 1, #args do
+          if i > #params then break end
+          new_env[params[i]] = args[i]
+        end
+        -- Clear all other unspecified parameter values or set them to their
+        -- defined defaults.
+        for i = #args + 1, #params do
+          new_env[params[i]] = defaults[params[i]]
+        end
+        -- Store extra parameters in "varargs" variable.
+        new_env.varargs = {}
+        for i = #params + 1, #args do
+          new_env.varargs[#new_env.varargs + 1] = args[i]
+        end
+        local chunk = evaluate(block, new_env)
+        if ast[i + 4] == 'lstrip' then chunk = chunk:gsub('%s*$', '') end
+        return chunk
+      end
+    elseif token == 'call' then
+      -- Parse the call block's parameter list (if any) and determine the macro
+      -- to call.
+      local param_list = block.expression:match('^(%b())')
+      local params = {}
+      if param_list then
+        for param in param_list:gmatch('[%w_]+') do
+          params[#params + 1] = param
+        end
+      end
+      local macro = block.expression:match('^%b()(.+)$') or block.expression
+      -- Evaluate the given macro, subject to a "caller" function that returns
+      -- the contents of this call block. Any arguments passed to the caller
+      -- function are used as values of this parameters parsed earlier.
+      local old_caller = M.env.caller -- save
+      M.env.caller = function(...)
+        local new_env = setmetatable({}, {__index = env})
+        local args = {...}
+        -- Assign the given parameter values (if any).
+        for i = 1, #args do new_env[params[i]] = args[i] end
+        local chunk = evaluate(block, new_env)
+        if ast[i + 4] == 'lstrip' then chunk = chunk:gsub('%s*$', '') end
+        return chunk
+      end
+      chunks[#chunks + 1] = eval(macro, env)
+      M.env.caller = old_caller -- restore
+    elseif token == 'filter' then
+      local text = evaluate(block, env)
+      local p = env._LUPAPOSITION -- mark position at beginning of expression
+      for pos, filter in each_filter(block.expression) do
+        env._LUPAPOSITION = p + pos - 1 -- update position for error messages
+        local name, params = filter:match('^%s*([%w_]+)%(?(.-)%)?%s*$')
+        local f = M.filters[name]
+        env_assert(env, f, 'unknown filter "'..name..'"')
+        local args = env_assert(env, load('return {'..params..'}'),
+                                'invalid filter parameter(s) for "'..name..
+                                '"')()
+        text = select(2, env_assert(env, pcall(f, text, table.unpack(args))))
+      end
+      chunks[#chunks + 1] = text
+    elseif token == 'set' then
+      local var, expr = block:match('^([%a_][%w_]*)%s*=%s*(.+)$')
+      env_assert(env, var and expr, 'invalid variable name or expression')
+      env[var] = eval(expr, env)
+    elseif token == 'do' then
+      env_assert(env, pcall(env_assert(env, load(block, nil, nil, env))))
+    elseif token == 'include' then
+      -- Parse the include block for flags.
+      local without_context = block:find('without%s+context%s*')
+      local ignore_missing = block:find('ignore%s+missing%s*')
+      block = block:gsub('witho?u?t?%s+context%s*', '')
+                   :gsub('ignore%s+missing%s*', '')
+      -- Evaluate the include expression in order to determine the file to
+      -- include. If the result is a table, use the first file that exists.
+      local file = eval(block, env) -- covers strings and variables
+      if type(file) == 'table' then
+        local files = file
+        for i = 1, #files do
+          file = loader(files[i], env)
+          if file then break end
+        end
+        if type(file) == 'table' then file = nil end
+      elseif type(file) == 'string' then
+        file = loader(file, env)
+      else
+        error('"include" requires a string or table of files')
+      end
+      -- If the file exists, include it. Otherwise throw an error unless the
+      -- "ignore missing" flag was given.
+      env_assert(env, file or ignore_missing, 'no file(s) found to include')
+      if file then
+        chunks[#chunks + 1] = M.expand_file(file, not without_context and env or
+                                                  M.env)
+      end
+    elseif token == 'import' then
+      local file, global = block:match('^%s*(.+)%s+as%s+([%a][%w_]*)%s*')
+      local new_env = setmetatable({}, {
+        __index = block:find('with%s+context%s*$') and env or M.env
+      })
+      M.expand_file(eval(file or block, env), new_env)
+      -- Copy any defined macros and variables over into the proper namespace.
+      if global then env[global] = {} end
+      local namespace = global and env[global] or env
+      for k, v in pairs(new_env) do if not env[k] then namespace[k] = v end end
+    elseif token == 'lstrip' and chunks[#chunks] then
+      chunks[#chunks] = chunks[#chunks]:gsub('%s*$', '')
+    elseif token == 'rstrip' then
+      rstrip = true -- can only strip after determining the next chunk
+    end
+    if rstrip and token ~= 'rstrip' then
+      chunks[#chunks] = chunks[#chunks]:gsub('^%s*', '')
+      rstrip = false
+    end
+  end
+  return not extends and table.concat(chunks) or M.expand_file(extends, env)
+end
+
+local pairs_gen, ipairs_gen = pairs({}), ipairs({})
+
+-- Iterates over the generator *generator* subject to string "if" expression
+-- *if_expr*, assigns that generator's returned values to the variable names
+-- listed in *variables* within template environment *env*, evaluates any
+-- expressions in *block* (a portion of a template's AST), and returns a
+-- concatenation of the results.
+-- @param generator Either a function that returns a generator function, or a
+--   table to iterate over. In the latter case, `ipairs()` is used as the
+--   generator function.
+-- @param variables List of variable names to assign values returned by
+--   *generator* to.
+-- @param if_expr A conditional expression that when `false`, skips the current
+--   loop item.
+-- @param block The portion inside the 'for' structure of a template's AST to
+--   iterate with.
+-- @param env The environment iteration variables are defined in and where
+--   expressions are evaluated in.
+-- @param depth The current recursion depth. Recursion is performed by calling
+--   `loop(t)` with a table to iterate over.
+-- @param lstrip Whether or not the "endfor" block strips whitespace on the
+--   left. When `true`, all blocks produced by iteration are left-stripped.
+iterate = function(generator, variables, if_expr, block, env, depth, lstrip)
+  local chunks = {}
+  local orig_variables = {} -- used to store original loop variables' values
+  for i = 1, #variables do orig_variables[variables[i]] = env[variables[i]] end
+  local i, n = 1 -- used for loop variables
+  local _, s, v -- state variables
+  if type(generator) == 'function' then
+    _, generator, s, v = env_assert(env, pcall(generator))
+    -- In practice, a generator's state variable is normally unused and hidden.
+    -- This is not the case for 'pairs()' and 'ipairs', though.
+    if variables[1] ~= '_index' and generator ~= pairs_gen and
+       generator ~= ipairs_gen then
+      table.insert(variables, 1, '_index')
+    end
+  end
+  if type(generator) == 'table' then
+    n = #generator
+    generator, s, v = ipairs(generator)
+    -- "for x in y" translates to "for _, x in ipairs(y)"; hide _ state variable
+    if variables[1] ~= '_index' then table.insert(variables, 1, '_index') end
+  end
+  if generator then
+    local first_results -- for preventing infinite loop from invalid generator
+    while true do
+      local results = {generator(s, v)}
+      if results[1] == nil then break end
+      -- If the results from the generator look like results returned by a
+      -- generator itself (function, state, initial variable), verify last two
+      -- results are different. If they are the same, then the original
+      -- generator is invalid and will loop infinitely.
+      if first_results == nil then
+        first_results = #results == 3 and type(results[1]) == 'function' and
+                        results
+      elseif first_results then
+        env_assert(env, results[3] ~= first_results[3] or
+                        results[2] ~= first_results[2],
+                   'invalid generator (infinite loop)')
+      end
+      -- Assign context variables and evaluate the body of the loop.
+      -- As long as the result (ignoring the _index variable) is not a single
+      -- table and there is only one loop variable defined (again, ignoring
+      -- _index variable), assignment occurs as normal in Lua. Otherwise,
+      -- unpacking on the table is done (like assignment to ...).
+      if not (type(results[2]) == 'table' and #results == 2 and
+              #variables > 2) then
+        for j = 1, #variables do env[variables[j]] = results[j] end
+      else
+        for j = 2, #variables do env[variables[j]] = results[2][j - 1] end
+      end
+      if not if_expr or eval(if_expr, env) then
+        env.loop = setmetatable({
+          index = i, index0 = i - 1,
+          revindex = n and n - (i - 1), revindex0 = n and n - i,
+          first = i == 1, last = i == n, length = n,
+          cycle = function(...)
+            return select((i - 1) % select('#', ...) + 1, ...)
+          end,
+          depth = depth, depth0 = depth - 1
+        }, {__call = function(_, t)
+          return iterate(t, variables, if_expr, block, env, depth + 1, lstrip)
+        end})
+        chunks[#chunks + 1] = evaluate(block, env)
+        if lstrip then chunks[#chunks] = chunks[#chunks]:gsub('%s*$', '') end
+        i = i + 1
+      end
+      -- Prepare for next iteration.
+      v = results[1]
+    end
+  end
+  if i == 1 and block['else'] then
+    chunks[#chunks + 1] = evaluate(block['else'], env)
+  end
+  for i = 1, #variables do env[variables[i]] = orig_variables[variables[i]] end
+  return table.concat(chunks)
+end
+
+-- Expands string template *template* from source *source*, subject to template
+-- environment *env*, and returns the result.
+-- @param template String template to expand.
+-- @param env Environment for the given template.
+-- @param source Filename or identifier the template comes from for error
+--   messages and debugging.
+local function expand(template, env, source)
+  template = template:gsub('\r?\n', newline_sequence) -- normalize
+  if not keep_trailing_newline then template = template:gsub('\r?\n$', '') end
+  -- Set up environment.
+  if not env then env = {} end
+  if not getmetatable(env) then env = setmetatable(env, {__index = M.env}) end
+  env.self = setmetatable({}, {__index = function(_, k)
+    env_assert(env, env._LUPABLOCKS and env._LUPABLOCKS[k],
+               'undefined block "'..k..'"')
+    return function() return evaluate(env._LUPABLOCKS[k][1], env) end
+  end})
+  -- Set context variables and expand the template.
+  env._LUPASOURCE, env._LUPAFILENAME = template, source
+  M._FILENAME = source -- for lpeg errors only
+  local ast = assert(lpeg.match(M.grammar, template), "internal error")
+  local result = evaluate(ast, env)
+  return result
+end
+
+---
+-- Expands the string template *template*, subject to template environment
+-- *env*, and returns the result.
+-- @param template String template to expand.
+-- @param env Optional environment for the given template.
+-- @name expand
+function M.expand(template, env) return expand(template, env, '<string>') end
+
+---
+-- Expands the template within file *filename*, subject to template environment
+-- *env*, and returns the result.
+-- @param filename Filename containing the template to expand.
+-- @param env Optional environment for the template to expand.
+-- @name expand_file
+function M.expand_file(filename, env)
+  filename = loader(filename, env) or filename
+  local f = (not env or not env._LUPASOURCE) and assert(io.open(filename)) or
+            env_assert(env, io.open(filename))
+  local template = f:read('*a')
+  f:close()
+  return expand(template, env, filename)
+end
+
+---
+-- Returns a loader for templates that uses the filesystem starting at directory
+-- *directory*.
+-- When looking up the template for a given filename, the loader considers the
+-- following: if no template is being expanded, the loader assumes the given
+-- filename is relative to *directory* and returns the full path; otherwise the
+-- loader assumes the given filename is relative to the current template's
+-- directory and returns the full path.
+-- The returned path may be passed to `io.open()`.
+-- @param directory Optional the template root directory. The default value is
+--   ".", which is the current working directory.
+-- @name loaders.filesystem
+-- @see configure
+function M.loaders.filesystem(directory)
+  return function(filename, env)
+    if not filename then return nil end
+    local current_dir = env and env._LUPAFILENAME and
+                        env._LUPAFILENAME:match('^(.+)[/\\]')
+    if not filename:find('^/') and not filename:find('^%a:[/\\]') then
+      filename = (current_dir or directory or '.')..'/'..filename
+    end
+    local f = io.open(filename)
+    if not f then return nil end
+    f:close()
+    return filename
+  end
+end
+
+-- Globally defined functions.
+
+---
+-- Returns a sequence of integers from *start* to *stop*, inclusive, in
+-- increments of *step*.
+-- The complete sequence is generated at once -- no generator is returned.
+-- @param start Optional number to start at. The default value is `1`.
+-- @param stop Number to stop at.
+-- @param step Optional increment between sequence elements. The default value
+--   is `1`.
+-- @name _G.range
+function range(start, stop, step)
+  if not stop and not step then stop, start = start, 1 end
+  if not step then step = 1 end
+  local t = {}
+  for i = start, stop, step do t[#t + 1] = i end
+  return t
+end
+
+---
+-- Returns an object that cycles through the given values by calls to its
+-- `next()` function.
+-- A `current` field contains the cycler's current value and a `reset()`
+-- function resets the cycler to its beginning.
+-- @param ... Values to cycle through.
+-- @usage c = cycler(1, 2, 3)
+-- @usage c:next(), c:next() --> 1, 2
+-- @usage c:reset() --> c.current == 1
+-- @name _G.cycler
+function cycler(...)
+  local c = {...}
+  c.n, c.i, c.current = #c, 1, c[1]
+  function c:next()
+    local current = self.current
+    self.i = self.i + 1
+    if self.i > self.n then self.i = 1 end
+    self.current = self[self.i]
+    return current
+  end
+  function c:reset() self.i, self.current = 1, self[1] end
+  return c
+end
+
+-- Create the default sandbox environment for templates.
+local safe = {
+  -- Lua globals.
+  '_VERSION', 'ipairs', 'math', 'pairs', 'select', 'tonumber', 'tostring',
+  'type', 'bit32', 'os.date', 'os.time', 'string', 'table', 'utf8',
+  -- Lupa globals.
+  'range', 'cycler'
+}
+local sandbox_env = setmetatable({}, {__index = M.tests})
+for i = 1, #safe do
+  local v = safe[i]
+  if not v:find('%.') then
+    sandbox_env[v] = _G[v]
+  else
+    local mod, func = v:match('^([^.]+)%.(.+)$')
+    if not sandbox_env[mod] then sandbox_env[mod] = {} end
+    sandbox_env[mod][func] = _G[mod][func]
+  end
+end
+sandbox_env._G = sandbox_env
+
+---
+-- Resets Lupa's default delimiters, options, and environments to their
+-- original default values.
+-- @name reset
+function M.reset()
+  M.configure('{%', '%}', '{{', '}}', '{#', '#}')
+  M.env = setmetatable({}, {__index = sandbox_env})
+end
+M.reset()
+
+---
+-- The default template environment.
+-- @class table
+-- @name env
+local env
+
+-- Lupa filters.
+
+---
+-- Returns the absolute value of number *n*.
+-- @param n The number to compute the absolute value of.
+-- @name filters.abs
+M.filters.abs = math.abs
+
+-- Returns a table that, when indexed with an integer, indexes table *t* with
+-- that integer along with string *attribute*.
+-- This is used by filters that operate on particular attributes of table
+-- elements.
+-- @param t The table to index.
+-- @param attribute The additional attribute to index with.
+local function attr_accessor(t, attribute)
+  return setmetatable({}, {__index = function(_, i)
+    local value = t[i]
+    attribute = tonumber(attribute) or attribute
+    if type(attribute) == 'number' then return value[attribute] end
+    for k in attribute:gmatch('[^.]+') do value = value[k] end
+    return value
+  end})
+end
+
+---
+-- Returns a generator that produces all of the items in table *t* in batches
+-- of size *size*, filling any empty spaces with value *fill*.
+-- Combine this with the "list" filter to produce a list.
+-- @param t The table to split into batches.
+-- @param size The batch size.
+-- @param fill The value to use when filling in any empty space in the last
+--   batch.
+-- @usage expand('{% for i in {1, 2, 3}|batch(2, 0) %}{{ i|string }}
+--   {% endfor %}') --> {1, 2} {3, 0}
+-- @see filters.list
+-- @name filters.batch
+function M.filters.batch(t, size, fill)
+  assert(t, 'input to filter "batch" was nil instead of a table')
+  local n = #t
+  return function(t, i)
+    if i > n then return nil end
+    local batch = {}
+    for j = i, i + size - 1 do batch[j - i + 1] = t[j] end
+    if i + size > n and fill then
+      for j = n + 1, i + size - 1 do batch[#batch + 1] = fill end
+    end
+    return i + size, batch
+  end, t, 1
+end
+
+---
+-- Capitalizes string *s*.
+-- The first character will be uppercased, the others lowercased.
+-- @param s The string to capitalize.
+-- @usage expand('{{ "foo bar"|capitalize }}') --> Foo bar
+-- @name filters.capitalize
+function M.filters.capitalize(s)
+  assert(s, 'input to filter "capitalize" was nil instead of a string')
+  local first, rest = s:match('^(.)(.*)$')
+  return first and first:upper()..rest:lower() or s
+end
+
+---
+-- Centers string *s* within a string of length *width*.
+-- @param s The string to center.
+-- @param width The length of the centered string.
+-- @usage expand('{{ "foo"|center(9) }}') --> "   foo   "
+-- @name filters.center
+function M.filters.center(s, width)
+  assert(s, 'input to filter "center" was nil instead of a string')
+  local padding = (width or 80) - #s
+  local left, right = math.ceil(padding / 2), math.floor(padding / 2)
+  return ("%s%s%s"):format((' '):rep(left), s, (' '):rep(right))
+end
+
+---
+-- Returns value *value* or value *default*, depending on whether or not *value*
+-- is "true" and whether or not boolean *false_defaults* is `true`.
+-- @param value The value return if "true" or if `false` and *false_defaults*
+--   is `true`.
+-- @param default The value to return if *value* is `nil` or `false` (the latter
+--   applies only if *false_defaults* is `true`).
+-- @param false_defaults Optional flag indicating whether or not to return
+--   *default* if *value* is `false`. The default value is `false`.
+-- @usage expand('{{ false|default("no") }}') --> false
+-- @usage expand('{{ false|default("no", true) }') --> no
+-- @name filters.default
+function M.filters.default(value, default, false_defaults)
+  if value == nil or false_defaults and not value then return default end
+  return value
+end
+
+---
+-- Returns a table constructed from table *t* such that each element is a list
+-- that contains a single key-value pair and all elements are sorted according
+-- to string *by* (which is either "key" or "value") and boolean
+-- *case_sensitive*.
+-- @param value The table to sort.
+-- @param case_sensitive Optional flag indicating whether or not to consider
+--   case when sorting string values. The default value is `false`.
+-- @param by Optional string that specifies which of the key-value to sort by,
+--   either "key" or "value". The default value is `"key"`.
+-- @usage expand('{{ {b = 1, a = 2}|dictsort|string }}') --> {{"a", 2},
+--   {"b", 1}}
+-- @name filters.dictsort
+function M.filters.dictsort(t, case_sensitive, by)
+  assert(t, 'input to filter "dictsort" was nil instead of a table')
+  assert(not by or by == 'key' or by == 'value',
+         'filter "dictsort" can only sort tables by "key" or "value"')
+  local i = (not by or by == 'key') and 1 or 2
+  local items = {}
+  for k, v in pairs(t) do items[#items + 1] = {k, v} end
+  table.sort(items, function(a, b)
+    a, b = a[i], b[i]
+    if not case_sensitive then
+      if type(a) == 'string' then a = a:lower() end
+      if type(b) == 'string' then b = b:lower() end
+    end
+    return a < b
+  end)
+  return items
+end
+
+---
+-- Returns an HTML-safe copy of string *s*.
+-- @param s String to ensure is HTML-safe.
+-- @usage expand([[{{ '<">&'|e}}]]) --> &lt;&#34;&gt;&amp;
+-- @name filters.escape
+function M.filters.escape(s)
+  assert(s, 'input to filter "escape" was nil instead of a string')
+  return s:gsub('[<>"\'&]', {
+    ['<'] = '&lt;', ['>'] = '&gt;', ['"'] = '&#34;', ["'"] = '&#39;',
+    ['&'] = '&amp;'
+  })
+end
+
+---
+-- Returns an HTML-safe copy of string *s*.
+-- @param s String to ensure is HTML-safe.
+-- @usage expand([[{{ '<">&'|escape}}]]) --> &lt;&#34;&gt;&amp;
+-- @name filters.e
+function M.filters.e(s)
+  assert(s, 'input to filter "e" was nil instead of a string')
+  return M.filters.escape(s)
+end
+
+---
+-- Returns a human-readable, decimal (or binary, depending on boolean *binary*)
+-- file size for *bytes* number of bytes.
+-- @param bytes The number of bytes to return the size for.
+-- @param binary Flag indicating whether or not to report binary file size
+--    as opposed to decimal file size. The default value is `false`.
+-- @usage expand('{{ 1000|filesizeformat }}') --> 1.0 kB
+-- @name filters.filesizeformat
+function M.filters.filesizeformat(bytes, binary)
+  assert(bytes, 'input to filter "filesizeformat" was nil instead of a number')
+  local base = binary and 1024 or 1000
+  local units = {
+    binary and 'KiB' or 'kB', binary and 'MiB' or 'MB',
+    binary and 'GiB' or 'GB', binary and 'TiB' or 'TB',
+    binary and 'PiB' or 'PB', binary and 'EiB' or 'EB',
+    binary and 'ZiB' or 'ZB', binary and 'YiB' or 'YB'
+  }
+  if bytes < base then
+    return string.format('%d Byte%s', bytes, bytes > 1 and 's' or '')
+  else
+    local limit, unit
+    for i = 1, #units do
+      limit, unit = base^(i + 1), units[i]
+      if bytes < limit then break end
+    end
+    return string.format('%.1f %s', (base * bytes / limit), unit)
+  end
+end
+
+---
+-- Returns the first element in table *t*.
+-- @param t The table to get the first element of.
+-- @usage expand('{{ range(10)|first }}') --> 1
+-- @name filters.first
+function M.filters.first(t)
+  assert(t, 'input to filter "first" was nil instead of a table')
+  return t[1]
+end
+
+---
+-- Returns value *value* as a float.
+-- This filter only works in Lua 5.3, which has a distinction between floats and
+-- integers.
+-- @param value The value to interpret as a float.
+-- @usage expand('{{ 42|float }}') --> 42.0
+-- @name filters.float
+function M.filters.float(value)
+  assert(value, 'input to filter "float" was nil instead of a number')
+  return (tonumber(value) or 0) * 1.0
+end
+
+---
+-- Returns an HTML-safe copy of value *value*, even if *value* was returned by
+-- the "safe" filter.
+-- @param value Value to ensure is HTML-safe.
+-- @usage expand('{% set x = "<div />"|safe %}{{ x|forceescape }}') -->
+--   &lt;div /&gt;
+-- @name filters.forceescape
+function M.filters.forceescape(value)
+  assert(value, 'input to filter "forceescape" was nil instead of a string')
+  return M.filters.escape(tostring(value))
+end
+
+---
+-- Returns the given arguments formatted according to string *s*.
+-- See Lua's `string.format()` for more information.
+-- @param s The string to format subsequent arguments according to.
+-- @param ... Arguments to format.
+-- @usage expand('{{ "%s,%s"|format("a", "b") }}') --> a,b
+-- @name filters.format
+function M.filters.format(s, ...)
+  assert(s, 'input to filter "format" was nil instead of a string')
+  return string.format(s, ...)
+end
+
+---
+-- Returns a generator that produces lists of items in table *t* grouped by
+-- string attribute *attribute*.
+-- @param t The table to group items from.
+-- @param attribute The attribute of items in the table to group by. This may
+--   be nested (e.g. "foo.bar" groups by t[i].foo.bar for all i).
+-- @usage expand('{% for age, group in people|groupby("age") %}...{% endfor %}')
+-- @name filters.groupby
+function M.filters.groupby(t, attribute)
+  assert(t, 'input to filter "groupby" was nil instead of a table')
+  local n = #t
+  local seen = {} -- keep track of groupers in order to avoid duplicates
+  return function(t, i)
+    if i > n then return nil end
+    local ta = attr_accessor(t, attribute)
+    -- Determine the next grouper.
+    local grouper = ta[i]
+    while seen[grouper] do
+      i = i + 1
+      if i > n then return nil end
+      grouper = ta[i]
+    end
+    seen[grouper] = true
+    -- Create and return the group.
+    local group = {}
+    for j = i, #t do if ta[j] == grouper then group[#group + 1] = t[j] end end
+    return i + 1, grouper, group
+  end, t, 1
+end
+
+---
+-- Returns a copy of string *s* with all lines after the first indented by
+-- *width* number of spaces.
+-- If boolean *first_line* is `true`, indents the first line as well.
+-- @param s The string to indent lines of.
+-- @param width The number of spaces to indent lines with.
+-- @param first_line Optional flag indicating whether or not to indent the
+--   first line of text. The default value is `false`.
+-- @usage expand('{{ "foo\nbar"|indent(2) }}') --> "foo\n  bar"
+-- @name filters.indent
+function M.filters.indent(s, width, first_line)
+  assert(s, 'input to filter "indent" was nil instead of a string')
+  local indent = (' '):rep(width)
+  return (first_line and indent or '')..s:gsub('([\r\n]+)', '%1'..indent)
+end
+
+---
+-- Returns value *value* as an integer.
+-- @param value The value to interpret as an integer.
+-- @usage expand('{{ 32.32|int }}') --> 32
+-- @name filters.int
+function M.filters.int(value)
+  assert(value, 'input to filter "int" was nil instead of a number')
+  return math.floor(tonumber(value) or 0)
+end
+
+---
+-- Returns a string that contains all the elements in table *t* (or all the
+-- attributes named *attribute* in *t*) separated by string *sep*.
+-- @param t The table to join.
+-- @param sep The string to separate table elements with.
+-- @param attribute Optional attribute of elements to use for joining instead
+--   of the elements themselves. This may be nested (e.g. "foo.bar" joins
+--   `t[i].foo.bar` for all i).
+-- @usage expand('{{ {1, 2, 3}|join("|") }}') --> 1|2|3
+-- @name filters.join
+function M.filters.join(t, sep, attribute)
+  assert(t, 'input to filter "join" was nil instead of a table')
+  if not attribute then
+    local strings = {}
+    for i = 1, #t do strings[#strings + 1] = tostring(t[i]) end
+    return table.concat(strings, sep)
+  end
+  local ta = attr_accessor(t, attribute)
+  local attributes = {}
+  for i = 1, #t do attributes[#attributes + 1] = ta[i] end
+  return table.concat(attributes, sep)
+end
+
+---
+-- Returns the last element in table *t*.
+-- @param t The table to get the last element of.
+-- @usage expand('{{ range(10)|last }}') --> 10
+-- @name filters.last
+function M.filters.last(t)
+  assert(t, 'input to filter "last" was nil instead of a table')
+  return t[#t]
+end
+
+---
+-- Returns the length of string or table *value*.
+-- @param value The value to get the length of.
+-- @usage expand('{{ "hello world"|length }}') --> 11
+-- @name filters.length
+function M.filters.length(value)
+  assert(value, 'input to filter "length" was nil instead of a table or string')
+  return #value
+end
+
+---
+-- Returns the list of items produced by generator *generator*, subject to
+-- initial state *s* and initial iterator variable *i*.
+-- This filter should only be used after a filter that returns a generator.
+-- @param generator Generator function that produces an item.
+-- @param s Initial state for the generator.
+-- @param i Initial iterator variable for the generator.
+-- @usage expand('{{ range(4)|batch(2)|list|string }}') --> {{1, 2}, {3, 4}}
+-- @see filters.batch
+-- @see filters.groupby
+-- @see filters.slice
+-- @name filters.list
+function M.filters.list(generator, s, i)
+  assert(type(generator) == 'function',
+         'input to filter "list" must be a generator')
+  local list = {}
+  for _, v in generator, s, i do list[#list + 1] = v end
+  return list
+end
+
+---
+-- Returns a copy of string *s* with all lowercase characters.
+-- @param s The string to lowercase.
+-- @usage expand('{{ "FOO"|lower }}') --> foo
+-- @name filters.lower
+function M.filters.lower(s)
+  assert(s, 'input to filter "lower" was nil instead of a string')
+  return string.lower(s)
+end
+
+---
+-- Maps each element of table *t* to a value produced by filter name *filter*
+-- and returns the resultant table.
+-- @param t The table of elements to map.
+-- @param filter The name of the filter to pass table elements through.
+-- @param ... Any arguments for the filter.
+-- @usage expand('{{ {"1", "2", "3"}|map("int")|sum }}') --> 6
+-- @name filters.map
+function M.filters.map(t, filter, ...)
+  assert(t, 'input to filter "map" was nil instead of a table')
+  local f = M.filters[filter]
+  assert(f, 'unknown filter "'..filter..'"')
+  local map = {}
+  for i = 1, #t do map[i] = f(t[i], ...) end
+  return map
+end
+
+---
+-- Maps the value of each element's string *attribute* in table *t* to the
+-- value produced by filter name *filter* and returns the resultant table.
+-- @param t The table of elements with attributes to map.
+-- @param attribute The attribute of elements in the table to filter. This may
+--   be nested (e.g. "foo.bar" maps t[i].foo.bar for all i).
+-- @param filter The name of the filter to pass table elements through.
+-- @param ... Any arguments for the filter.
+-- @usage expand('{{ users|mapattr("name")|join("|") }}')
+-- @name filters.mapattr
+function M.filters.mapattr(t, attribute, filter, ...)
+  assert(t, 'input to filter "mapattr" was nil instead of a table')
+  local ta = attr_accessor(t, attribute)
+  local f = M.filters[filter]
+  if filter then
+    assert(f, 'unknown filter "'..filter..'" given to filter "mapattr"')
+  end
+  local map = {}
+  for i = 1, #t do map[i] = filter and f(ta[i], ...) or ta[i] end
+  return map
+end
+
+---
+-- Returns a random element from table *t*.
+-- @param t The table to get a random element from.
+-- @usage expand('{{ range(100)|random }}')
+-- @name filters.random
+function M.filters.random(t)
+  assert(t, 'input to filter "random" was nil instead of a table')
+  math.randomseed(os.time())
+  return t[math.random(#t)]
+end
+
+---
+-- Returns a list of elements in table *t* that fail test name *test*.
+-- @param t The table of elements to reject from.
+-- @param test The name of the test to use on table elements.
+-- @param ... Any arguments for the test.
+-- @usage expand('{{ range(5)|reject(is_odd)|join("|") }}') --> 2|4
+-- @name filters.reject
+function M.filters.reject(t, test, ...)
+  assert(t, 'input to filter "reject" was nil instead of a table')
+  local f = test or function(value) return not not value end
+  local items = {}
+  for i = 1, #t do if not f(t[i], ...) then items[#items + 1] = t[i] end end
+  return items
+end
+
+---
+-- Returns a list of elements in table *t* whose string attribute *attribute*
+-- fails test name *test*.
+-- @param t The table of elements to reject from.
+-- @param attribute The attribute of items in the table to reject from. This
+--   may be nested (e.g. "foo.bar" tests t[i].foo.bar for all i).
+-- @param test The name of the test to use on table elements.
+-- @param ... Any arguments for the test.
+-- @usage expand('{{ users|rejectattr("offline")|mapattr("name")|join(",") }}')
+-- @name filters.rejectattr
+function M.filters.rejectattr(t, attribute, test, ...)
+  assert(t, 'input to filter "rejectattr" was nil instead of a table')
+  local ta = attr_accessor(t, attribute)
+  local f = test or function(value) return not not value end
+  local items = {}
+  for i = 1, #t do if not f(ta[i], ...) then items[#items + 1] = t[i] end end
+  return items
+end
+
+---
+-- Returns a copy of string *s* with all (or up to *n*) occurrences of string
+-- *old* replaced by string *new*.
+-- Identical to Lua's `string.gsub()` and handles Lua patterns.
+-- @param s The subject string.
+-- @param pattern The string or Lua pattern to replace.
+-- @param repl The replacement text (may contain Lua captures).
+-- @param n Optional number indicating the maximum number of replacements to
+--   make. The default value is `nil`, which is unlimited.
+-- @usage expand('{% filter upper|replace("FOO", "foo") %}foobar
+--   {% endfilter %}') --> fooBAR
+-- @name filters.replace
+function M.filters.replace(s, pattern, repl, n)
+  assert(s, 'input to filter "replace" was nil instead of a string')
+  return string.gsub(s, pattern, repl, n)
+end
+
+---
+-- Returns a copy of the given string or table *value* in reverse order.
+-- @param value The value to reverse.
+-- @usage expand('{{ {1, 2, 3}|reverse|string }}') --> {3, 2, 1}
+-- @name filters.reverse
+function M.filters.reverse(value)
+  assert(type(value) == 'table' or type(value) == 'string',
+         'input to filter "reverse" was nil instead of a table or string')
+  if type(value) == 'string' then return value:reverse() end
+  local t = {}
+  for i = 1, #value do t[i] = value[#value - i + 1] end
+  return t
+end
+
+---
+-- Returns number *value* rounded to *precision* decimal places based on string
+-- *method* (if given).
+-- @param value The number to round.
+-- @param precision Optional precision to round the number to. The default
+--   value is `0`.
+-- @param method Optional string rounding method, either `"ceil"` or
+--   `"floor"`. The default value is `nil`, which uses the common rounding
+--   method (if a number's fractional part is 0.5 or greater, rounds up;
+--   otherwise rounds down).
+-- @usage expand('{{ 2.1236|round(3, "floor") }}') --> 2.123
+-- @name filters.round
+function M.filters.round(value, precision, method)
+  assert(value, 'input to filter "round" was nil instead of a number')
+  assert(not method or method == 'ceil' or method == 'floor',
+         'rounding method given to filter "round" must be "ceil" or "floor"')
+  precision = precision or 0
+  method = method or (select(2, math.modf(value)) >= 0.5 and 'ceil' or 'floor')
+  local s = string.format('%.'..(precision >= 0 and precision or 0)..'f',
+                          math[method](value * 10^precision) / 10^precision)
+  return tonumber(s)
+end
+
+---
+-- Marks string *s* as HTML-safe, preventing Lupa from modifying it when
+-- configured to autoescape HTML entities.
+-- This filter must be used at the end of a filter chain unless it is
+-- immediately proceeded by the "forceescape" filter.
+-- @param s The string to mark as HTML-safe.
+-- @usage lupa.configure{autoescape = true}
+-- @usage expand('{{ "<div>foo</div>"|safe }}') --> <div>foo</div>
+-- @name filters.safe
+function M.filters.safe(s)
+  assert(s, 'input to filter "safe" was nil instead of a string')
+  return setmetatable({}, {__tostring = function() return s end})
+end
+
+---
+-- Returns a list of the elements in table *t* that pass test name *test*.
+-- @param t The table of elements to select from.
+-- @param test The name of the test to use on table elements.
+-- @param ... Any arguments for the test.
+-- @usage expand('{{ range(5)|select(is_odd)|join("|") }}') --> 1|3|5
+-- @name filters.select
+function M.filters.select(t, test, ...)
+  assert(t, 'input to filter "select" was nil instead of a table')
+  local f = test or function(value) return not not value end
+  local items = {}
+  for i = 1, #t do if f(t[i], ...) then items[#items + 1] = t[i] end end
+  return items
+end
+
+---
+-- Returns a list of elements in table *t* whose string attribute *attribute*
+-- passes test name *test*.
+-- @param t The table of elements to select from.
+-- @param attribute The attribute of items in the table to select from. This
+--   may be nested (e.g. "foo.bar" tests t[i].foo.bar for all i).
+-- @param test The name of the test to use on table elements.
+-- @param ... Any arguments for the test.
+-- @usage expand('{{ users|selectattr("online")|mapattr("name")|join("|") }}')
+-- @name filters.selectattr
+function M.filters.selectattr(t, attribute, test, ...)
+  assert(t, 'input to filter "selectattr" was nil instead of a table')
+  local ta = attr_accessor(t, attribute)
+  local f = test or function(value) return not not value end
+  local items = {}
+  for i = 1, #t do if f(ta[i], ...) then items[#items + 1] = t[i] end end
+  return items
+end
+
+---
+-- Returns a generator that produces all of the items in table *t* in *slices*
+-- number of iterations, filling any empty spaces with value *fill*.
+-- Combine this with the "list" filter to produce a list.
+-- @param t The table to slice.
+-- @param slices The number of slices to produce.
+-- @param fill The value to use when filling in any empty space in the last
+--   slice.
+-- @usage expand('{% for i in {1, 2, 3}|slice(2, 0) %}{{ i|string }}
+--   {% endfor %}') --> {1, 2} {3, 0}
+-- @see filters.list
+-- @name filters.slice
+function M.filters.slice(t, slices, fill)
+  assert(t, 'input to filter "slice" was nil instead of a table')
+  local size, slices_with_extra = math.floor(#t / slices), #t % slices
+  return function(t, i)
+    if i > slices then return nil end
+    local slice = {}
+    local s = (i - 1) * size + math.min(i, slices_with_extra + 1)
+    local e = i * size + math.min(i, slices_with_extra)
+    for j = s, e do slice[j - s + 1] = t[j] end
+    if slices_with_extra > 0 and i > slices_with_extra and fill then
+      slice[#slice + 1] = fill
+    end
+    return i + 1, slice
+  end, t, 1
+end
+
+---
+-- Returns a copy of table or string *value* in sorted order by value (or by
+-- an attribute named *attribute*), depending on booleans *reverse* and
+-- *case_sensitive*.
+-- @param value The table or string to sort.
+-- @param reverse Optional flag indicating whether or not to sort in reverse
+--   (descending) order. The default value is `false`, which sorts in ascending
+--   order.
+-- @param case_sensitive Optional flag indicating whether or not to consider
+--   case when sorting string values. The default value is `false`.
+-- @param attribute Optional attribute of elements to sort by instead of the
+--   elements themselves.
+-- @usage expand('{{ {2, 3, 1}|sort|string }}') --> {1, 2, 3}
+-- @name filters.sort
+function M.filters.sort(value, reverse, case_sensitive, attribute)
+  assert(value, 'input to filter "sort" was nil instead of a table or string')
+  assert(not attribute or type(attribute) == 'string' or
+         type(attribute) == 'number',
+         'attribute to filter "sort" must be a string or number')
+  local t = {}
+  local sort_string = type(value) == 'string'
+  if not sort_string then
+    for i = 1, #value do t[#t + 1] = value[i] end
+  else
+    for char in value:gmatch('.') do t[#t + 1] = char end -- chars in string
+  end
+  table.sort(t, function(a, b)
+    if attribute then
+      if type(attribute) == 'number' then
+        a, b = a[attribute], b[attribute]
+      else
+        for k in attribute:gmatch('[^.]+') do a, b = a[k], b[k] end
+      end
+    end
+    if not case_sensitive then
+      if type(a) == 'string' then a = a:lower() end
+      if type(b) == 'string' then b = b:lower() end
+    end
+    if not reverse then
+      return a < b
+    else
+      return a > b
+    end
+  end)
+  return not sort_string and t or table.concat(t)
+end
+
+---
+-- Returns the string representation of value *value*, handling lists properly.
+-- @param value Value to return the string representation of.
+-- @usage expand('{{ {1 * 1, 2 * 2, 3 * 3}|string }}') --> {1, 4, 9}
+-- @name filters.string
+function M.filters.string(value)
+  if type(value) ~= 'table' then return tostring(value) end
+  local t = {}
+  for i = 1, #value do
+    local item = value[i]
+    t[i] = type(item) == 'string' and '"'..item..'"' or M.filters.string(item)
+  end
+  return '{'..table.concat(t, ', ')..'}'
+end
+
+---
+-- Returns a copy of string *s* with any HTML tags stripped.
+-- Also cleans up whitespace.
+-- @param s String to strip HTML tags from.
+-- @usage expand('{{ "<div>foo</div>"|striptags }}') --> foo
+-- @name filters.striptags
+function M.filters.striptags(s)
+  assert(s, 'input to filter "striptags" was nil instead of a string')
+  return s:gsub('%b<>', ''):gsub('%s+', ' '):match('^%s*(.-)%s*$')
+end
+
+---
+-- Returns the numeric sum of the elements in table *t* or the sum of all
+-- attributes named *attribute* in *t*.
+-- @param t The table to calculate the sum of.
+-- @param attribute Optional attribute of elements to use for summing instead
+--   of the elements themselves. This may be nested (e.g. "foo.bar" sums
+--   `t[i].foo.bar` for all i).
+-- @usage expand('{{ range(6)|sum }}') --> 21
+-- @name filters.sum
+function M.filters.sum(t, attribute)
+  assert(t, 'input to filter "sum" was nil instead of a table')
+  local ta = attribute and attr_accessor(t, attribute) or t
+  local sum = 0
+  for i = 1, #t do sum = sum + ta[i] end
+  return sum
+end
+
+---
+-- Returns a copy of all words in string *s* in titlecase.
+-- @param s The string to titlecase.
+-- @usage expand('{{ "foo bar"|title }}') --> Foo Bar
+-- @name filters.title
+function M.filters.title(s)
+  assert(s, 'input to filter "title" was nil instead of a string')
+  return s:gsub('[^-%s]+', M.filters.capitalize)
+end
+
+---
+-- Returns a copy of string *s* truncated to *length* number of characters.
+-- Truncated strings end with '...' or string *delimiter*. If boolean
+-- *partial_words* is `false`, truncation will only happen at word boundaries.
+-- @param s The string to truncate.
+-- @param length The length to truncate the string to.
+-- @param partial_words Optional flag indicating whether or not to allow
+--   truncation within word boundaries. The default value is `false`.
+-- @param delimiter Optional delimiter text. The default value is '...'.
+-- @usage expand('{{ "foo bar"|truncate(4) }}') --> "foo ..."
+-- @name filters.truncate
+function M.filters.truncate(s, length, partial_words, delimiter)
+  assert(s, 'input to filter "truncate" was nil instead of a string')
+  if #s <= length then return s end
+  local truncated = s:sub(1, length)
+  if s:find('[%w_]', length) and not partial_words then
+    truncated = truncated:match('^(.-)[%w_]*$') -- drop partial word
+  end
+  return truncated..(delimiter or '...')
+end
+
+---
+-- Returns a copy of string *s* with all uppercase characters.
+-- @param s The string to uppercase.
+-- @usage expand('{{ "foo"|upper }}') --> FOO
+-- @name filters.upper
+function M.filters.upper(s)
+  assert(s, 'input to filter "upper" was nil instead of a string')
+  return string.upper(s)
+end
+
+---
+-- Returns a string suitably encoded to be used in a URL from value *value*.
+-- *value* may be a string, table of key-value query parameters, or table of
+-- lists of key-value query parameters (for order).
+-- @param value Value to URL-encode.
+-- @usage expand('{{ {{'f', 1}, {'z', 2}}|urlencode }}') --> f=1&z=2
+-- @name filters.urlencode
+function M.filters.urlencode(value)
+  assert(value,
+         'input to filter "urlencode" was nil instead of a string or table')
+  if type(value) ~= 'table' then
+    return tostring(value):gsub('[^%w.-]', function(c)
+      return string.format('%%%X', string.byte(c))
+    end)
+  end
+  local params = {}
+  if #value > 0 then
+    for i = 1, #value do
+      local k = M.filters.urlencode(value[i][1])
+      local v = M.filters.urlencode(value[i][2])
+      params[#params + 1] = k..'='..v
+    end
+  else
+    for k, v in pairs(value) do
+      params[#params + 1] = M.filters.urlencode(k)..'='..M.filters.urlencode(v)
+    end
+  end
+  return table.concat(params, '&')
+end
+
+---
+-- Replaces any URLs in string *s* with HTML links, limiting link text to
+-- *length* characters.
+-- @param s The string to replace URLs with HTML links in.
+-- @param length Optional maximum number of characters to include in link text.
+--   The default value is `nil`, which imposes no limit.
+-- @param nofollow Optional flag indicating whether or not HTML links will get a
+--   "nofollow" attribute.
+-- @usage expand('{{ "example.com"|urlize }}') -->
+--   <a href="http://example.com">example.com</a>
+-- @name filters.urlize
+function M.filters.urlize(s, length, nofollow)
+  assert(s, 'input to filter "urlize" was nil instead of a string')
+  -- Trims the given url.
+  local function trim_url(url)
+    return length and s:sub(1, length)..(#s > length and '...' or '') or url
+  end
+  local nofollow_attr = nofollow and ' rel="nofollow"' or ''
+  local lead, trail = C((S('(<') + '&lt;')^0), C((S('.,)>\n') + '&gt;')^0) * -1
+  local middle = C((1 - trail)^0)
+  local patt = lpeg.Cs(lead * middle * trail / function(lead, middle, trail)
+    local linked
+    if middle:find('^www%.') or (not middle:find('@') and
+                                 not middle:find('^https?://') and
+                                 #middle > 0 and middle:find('^%w') and (
+                                   middle:find('%.com$') or
+                                   middle:find('%.net$') or
+                                   middle:find('%.org$')
+                                 )) then
+      middle, linked = string.format('<a href="http://%s"%s>%s</a>', middle,
+                                     nofollow_attr, trim_url(middle)), true
+    end
+    if middle:find('^https?://') then
+      middle, linked = string.format('<a href="%s"%s>%s</a>', middle,
+                                     nofollow_attr, trim_url(middle)), true
+    end
+    if middle:find('@') and not middle:find('^www%.') and
+       not middle:find(':') and middle:find('^%S+@[%w._-]+%.[%w._-]+$') then
+      middle, linked = string.format('<a href="mailto:%s">%s</a>', middle,
+                                     middle), true
+    end
+    if linked then return lead..middle..trail end
+  end)
+  return M.filters.escape(s):gsub('%S+', function(word)
+    return lpeg.match(patt, word)
+  end)
+end
+
+---
+-- Returns the number of words in string *s*.
+-- A word is a sequence of non-space characters.
+-- @param s The string to count words in.
+-- @usage expand('{{ "foo bar baz"|wordcount }}') --> 3
+-- @name filters.wordcount
+function M.filters.wordcount(s)
+  assert(s, 'input to filter "wordcount" was nil instead of a string')
+  return select(2, s:gsub('%S+', ''))
+end
+
+---
+-- Interprets table *t* as a list of XML attribute-value pairs, returning them
+-- as a properly formatted, space-separated string.
+-- @param t The table of XML attribute-value pairs.
+-- @usage expand('<data {{ {foo = 42, bar = 23}|xmlattr }} />')
+-- @name filters.xmlattr
+function M.filters.xmlattr(t)
+  assert(t, 'input to filter "xmlattr" was nil instead of a table')
+  local attributes = {}
+  for k, v in pairs(t) do
+    attributes[#attributes + 1] = string.format('%s="%s"', k,
+                                                M.filters.escape(tostring(v)))
+  end
+  return table.concat(attributes, ' ')
+end
+
+-- Lupa tests.
+
+---
+-- Returns whether or not number *n* is odd.
+-- @param n The number to test.
+-- @usage expand('{% for x in range(10) if is_odd(x) %}...{% endif %}')
+-- @name tests.is_odd
+function M.tests.is_odd(n) return n % 2 == 1 end
+
+---
+-- Returns whether or not number *n* is even.
+-- @param n The number to test.
+-- @usage expand('{% for x in range(10) if is_even(x) %}...{% endif %}')
+-- @name tests.is_even
+function M.tests.is_even(n) return n % 2 == 0 end
+
+---
+-- Returns whether or not number *n* is evenly divisible by number *num*.
+-- @param n The dividend to test.
+-- @param num The divisor to use.
+-- @usage expand('{% if is_divisibleby(x, y) %}...{% endif %}')
+-- @name tests.is_divisibleby
+function M.tests.is_divisibleby(n, num) return n % num == 0 end
+
+---
+-- Returns whether or not value *value* is non-nil, and thus defined.
+-- @param value The value to test.
+-- @usage expand('{% if is_defined(x) %}...{% endif %}')
+-- @name tests.is_defined
+function M.tests.is_defined(value) return value ~= nil end
+
+---
+-- Returns whether or not value *value* is nil, and thus effectively undefined.
+-- @param value The value to test.
+-- @usage expand('{% if is_undefined(x) %}...{% endif %}')
+-- @name tests.is_undefined
+function M.tests.is_undefined(value) return value == nil end
+
+---
+-- Returns whether or not value *value* is nil.
+-- @param value The value to test.
+-- @usage expand('{% if is_none(x) %}...{% endif %}')
+-- @name tests.is_none
+function M.tests.is_none(value) return value == nil end
+
+---
+-- Returns whether or not value *value* is nil.
+-- @param value The value to test.
+-- @usage expand('{% if is_nil(x) %}...{% endif %}')
+-- @name tests.is_nil
+function M.tests.is_nil(value) return value == nil end
+
+---
+-- Returns whether or not string *s* is in all lower-case characters.
+-- @param s The string to test.
+-- @usage expand('{% if is_lower(s) %}...{% endif %}')
+-- @name tests.is_lower
+function M.tests.is_lower(s) return s:lower() == s end
+
+---
+-- Returns whether or not string *s* is in all upper-case characters.
+-- @param s The string to test.
+-- @usage expand('{% if is_upper(s) %}...{% endif %}')
+-- @name tests.is_upper
+function M.tests.is_upper(s) return s:upper() == s end
+
+---
+-- Returns whether or not value *value* is a string.
+-- @param value The value to test.
+-- @usage expand('{% if is_string(x) %}...{% endif %}')
+-- @name tests.is_string
+function M.tests.is_string(value) return type(value) == 'string' end
+
+---
+-- Returns whether or not value *value* is a table.
+-- @param value The value to test.
+-- @usage expand('{% if is_mapping(x) %}...{% endif %}')
+-- @name tests.is_mapping
+function M.tests.is_mapping(value) return type(value) == 'table' end
+
+---
+-- Returns whether or not value *value* is a table.
+-- @param value The value to test.
+-- @usage expand('{% if is_table(x) %}...{% endif %}')
+-- @name tests.is_table
+function M.tests.is_table(value) return type(value) == 'table' end
+
+---
+-- Returns whether or not value *value* is a number.
+-- @param value The value to test.
+-- @usage expand('{% if is_number(x) %}...{% endif %}')
+-- @name tests.is_number
+function M.tests.is_number(value) return type(value) == 'number' end
+
+---
+-- Returns whether or not value *value* is a sequence, namely a table with
+-- non-zero length.
+-- @param value The value to test.
+-- @usage expand('{% if is_sequence(x) %}...{% endif %}')
+-- @name tests.is_sequence
+function M.tests.is_sequence(value)
+  return type(value) == 'table' and #value > 0
+end
+
+---
+-- Returns whether or not value *value* is a sequence (a table with non-zero
+-- length) or a generator.
+-- At the moment, all functions are considered generators.
+-- @param value The value to test.
+-- @usage expand('{% if is_iterable(x) %}...{% endif %}')
+-- @name tests.is_iterable
+function M.tests.is_iterable(value)
+  return M.tests.is_sequence(value) or type(value) == 'function'
+end
+
+---
+-- Returns whether or not value *value* is a function.
+-- @param value The value to test.
+-- @usage expand('{% if is_callable(x) %}...{% endif %}')
+-- @name tests.is_callable
+function M.tests.is_callable(value) return type(value) == 'function' end
+
+---
+-- Returns whether or not value *value* is the same as value *other*.
+-- @param value The value to test.
+-- @param other The value to compare with.
+-- @usage expand('{% if is_sameas(x, y) %}...{% endif %}')
+-- @name tests.is_sameas
+function M.tests.is_sameas(value, other) return value == other end
+
+---
+-- Returns whether or not value *value* is HTML-safe.
+-- @param value The value to test.
+-- @usage expand('{% if is_escaped(x) %}...{% endif %}')
+-- @name tests.is_escaped
+function M.tests.is_escaped(value)
+  return getmetatable(value) and getmetatable(value).__tostring ~= nil
+end
+
+return M