鏡像自
https://github.com/rspamd/rspamd.git
synced 2024-07-29 08:17:22 +02:00
1808 行
72 KiB
Lua
1808 行
72 KiB
Lua
-- 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}}]]) --> <">&
|
|
-- @name filters.escape
|
|
function M.filters.escape(s)
|
|
assert(s, 'input to filter "escape" was nil instead of a string')
|
|
return s:gsub('[<>"\'&]', {
|
|
['<'] = '<', ['>'] = '>', ['"'] = '"', ["'"] = ''',
|
|
['&'] = '&'
|
|
})
|
|
end
|
|
|
|
---
|
|
-- Returns an HTML-safe copy of string *s*.
|
|
-- @param s String to ensure is HTML-safe.
|
|
-- @usage expand([[{{ '<">&'|escape}}]]) --> <">&
|
|
-- @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 }}') -->
|
|
-- <div />
|
|
-- @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('(<') + '<')^0), C((S('.,)>\n') + '>')^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
|