* @copyright 2010-2018 Paul Duncan * @license MIT * @package Pablotron\Luigi */ declare(strict_types = 1); /** * Luigi Template namespace. * * @api */ namespace Pablotron\Luigi; /** * Current version of Luigi Template. * * @api */ const VERSION = '0.4.2'; /** * Base class for all exceptions raised by Luigi Template. */ class LuigiError extends \Exception {}; /** * Base class for all all unknown type errors. */ class UnknownTypeError extends LuigiError { /** * @var string $type Unknown item type name ("method", "template", etc). * @var string $name Unknown item name. */ public $type, $name; /** * Create a new UnknownTypeError error. * * @param string $type Unknown item type name. * @param string $name Unknown item name. */ public function __construct(string $type, string $name) { $this->type = $type; $this->name = $name; parent::__construct("unknown $type: $name"); } }; /** * Thrown when attempting to get an unknown template from a Cache. * * @see Cache */ final class UnknownTemplateError extends UnknownTypeError { /** * Create a new UnknownTemplateError. * * @param string $name Unknown template name. */ public function __construct(string $name) { parent::__construct('template', $name); } }; /** * Thrown when attempting to apply an unknown filter. */ final class UnknownFilterError extends UnknownTypeError { /** * Create a new UnknownFilterError. * * @param string $name Unknown filter name. */ public function __construct(string $name) { parent::__construct('filter', $name); } }; /** * Thrown when attempting to get an unknown key. */ final class UnknownKeyError extends UnknownTypeError { /** * Create a new UnknownKeyError. * * @param string $name Unknown key name. */ public function __construct(string $name) { parent::__construct('key', $name); } }; /** * Thrown when trying to use a filter with with a missing parameter. */ final class MissingFilterParameterError extends LuigiError { /** @var string $filter_name Name of filter. */ public $filter_name; /** * Create a new MissingFilterParameterError. * * @param string $filter_name Name of filter. */ public function __construct(string $filter_name) { $this->filter_name = $filter_name; parent::__construct("missing required filter parameter for filter $filter_name"); } }; /** * Thrown when trying to parse an invalid template string. */ final class InvalidTemplateError extends LuigiError { /** @var string $template Template string. */ public $template; /** * Create a new InvalidTemplateError. * * @param string $template Template string. */ public function __construct(string $template) { $this->template = $template; parent::__construct("invalid template: $template"); } }; /** * Wrapper for context during Template#run(). * * @internal */ final class RunContext { /** * @var array $args Hash of arguments. * @var array $filters Hash of filters. */ public $args, $filters; /** * Create a RunContext. * * @internal * * @param array $args Arguments hash. * @param array $filters Filters hash. */ public function __construct(array $args, array $filters) { $this->args = $args; $this->filters = $filters; } }; /** * Parsed filter name and arguments. * * @internal */ final class TemplateFilter { /** * @var string $name Filter name. * @var array $args Filter arguments. */ private $name, $args; /** * Create a TemplateFilter. * * @internal * * @param string $name Filter name. * @param array $args Filter arguments. */ public function __construct(string $name, array $args) { $this->name = $name; $this->args = $args; } /** * Run filter on given value, arguments, and filter set. * * @internal * * @param string $v Input value. * @param array $args Hash passed to Template#run(). * @param array $filters Hash of filters. * * @return mixed Filter result. * * @throws UnknownFilterError If this filter does not exist in filter * hash. */ public function run($v, array &$args, array &$filters) { if (!isset($filters[$this->name])) { throw new UnknownFilterError($this->name); } # get callback $cb = $filters[$this->name]; # invoke callback, return result return $cb($v, $this->args, $args); } }; /** * Abstract base class for parser tokens. * * @internal */ abstract class Token { /** * Apply this token. * * @internal * * @param RunContext $ctx Run context. * * @return string */ public abstract function run(RunContext &$ctx) : string; }; /** * Literal parser token. * * @internal */ final class LiteralToken extends Token { /** @var string $val Literal value. */ private $val; /** * Create a new LiteralToken. * * @internal * * @param string $val Literal string. */ public function __construct(string $val) { $this->val = $val; } /** * Returns the literal value. * * @internal * * @param RunContext $ctx Run context. * * @return string Literal value. */ public function run(RunContext &$ctx) : string { return $this->val; } }; /** * Filter parser token. * * @internal */ final class FilterToken extends Token { /** * @var string $key Argument name. * @var array $filters Array of TemplateFilter instances. */ private $key, $filters; /** * Create a new LiteralToken. * * @internal * * @param string $key Argument name. * @param array $filters Array of TemplateFilter instances. */ public function __construct(string $key, array $filters) { $this->key = $key; $this->filters = $filters; } /** * Get key from arguments hash and apply filters to it, then return * the result. * * @internal * * @param RunContext $ctx Run context. * * @return string Filtered result. * * @throws UnknownKeyError If the given key does not exist in the * arguments hash. */ public function run(RunContext &$ctx) : string { # check key if (!isset($ctx->args[$this->key])) { throw new UnknownKeyError($this->key); } # get initial value $r = $ctx->args[$this->key]; if ($this->filters && count($this->filters) > 0) { # pass value through filters $r = array_reduce($this->filters, function($r, $f) use (&$ctx) { return $f->run($r, $ctx->args, $ctx->filters); }, $r); } # return result return $r; } }; /** * Token parser regular expression. * * @internal */ const TOKEN_RE = '/ # match opening brace %\{ # match optional whitespace \s* # match key (?[^\s\|\}]+) # match filter(s) (?(\s*\|(\s*[^\s\|\}]+)+)*) # match optional whitespace \s* # match closing brace \} # or match up all non-% chars or a single % char | (?[^%]* | %) /mx'; /** * Filter parser regular expression. * * @internal */ const FILTER_RE = '/ # match filter name (?\S+) # match filter arguments (optional) (?(\s*\S+)*) # optional trailing whitespace \s* /mx'; /** * Filter delimiter regular expression. * * @internal */ const DELIM_FILTERS_RE = '/\s*\|\s*/m'; /** * Filter argument delimiter regular expression. * * @internal */ const DELIM_ARGS_RE = '/\s+/m'; /** * Parse a string containing a filter clause into an array of * TemplateFilter instances. * * @internal * * @param string $filters Input filter string. * * @return array Array of TemplateFilter instances. * * @throws UnknownFilterError if a filter clause could not be parsed. */ function parse_filters(string $filters) : array { # split into individual filters $r = []; foreach (preg_split(DELIM_FILTERS_RE, $filters) as $f) { # trim whitespace $f = trim($f); # skip empty filters if (!$f) continue; # match filter $md = []; if (!preg_match(FILTER_RE, $f, $md)) { throw new UnknownFilterError($f); } # add filter to results $r[] = new TemplateFilter($md['name'], (count($md) > 2) ? preg_split( DELIM_ARGS_RE, trim($md['args']) ) : []); } # return results return $r; } /** * Parse a template string into an array of Token instances. * * @internal * * @param string $template Input template string. * * @return array Array of Token instances. * * @throws InvalidTemplateError if the template could not be parsed. */ function parse_template(string $template) : array { # build list of matches $matches = []; $num_matches = preg_match_all(TOKEN_RE, $template, $matches, PREG_SET_ORDER); # check for error if ($num_matches === false) { throw new InvalidTemplateError($template); } # map matches to tokens return array_reduce($matches, function($r, $m) { if ($m['key'] !== '') { # filter token $r[] = new FilterToken($m['key'], parse_filters($m['filters'])); } else if (strlen($m['text']) > 0) { # literal token $r[] = new LiteralToken($m['text']); } else { # ignore empty string } return $r; }, []); } /** * Static class containing global filter map. * * The built-in default filters are: * * * `h`: HTML-escape input string (including quotes). * * `u`: URL-escape input string. * * `json`: JSON-encode input value. * * `hash`: Hash input value. Requires hash algorithm as parameter. * * `base64`: Base64-encode input string. * * `nl2br`: Convert newlines to `\
` elements. * * `uc`: Upper-case input string. * * `lc`: Lower-case input string. * * `trim`: Trim leading and trailing whitespace from input string. * * `ltrim`: Trim leading whitespace from input string. * * `rtrim`: Trim trailing whitespace from input string. * * `s`: Return '' if input number is 1, and 's' otherwise (used for * pluralization). * * `strlen`: Return the length of the input string. * * `count`: Return the number of elements in the input array. * * `key`: Get the given key from the input array. Requires key as a * parameter. * * You can add your own filters to the default set of filters by * modifying `\Luigi\Filters::$FILTERS`, like so: * * # add a filter named "my-filter" * \Luigi\Filters['my-filter'] = function($s) { * # filter input string * return "foo-{$s}-bar"; * }; * * # use custom filter in template * echo Template::once('%{some-key | my-filter}', [ * 'some-key' => 'example', * ]) . "\n"; * * # prints "foo-example-bar" * * @api */ class Filters { /** * @var array $FILTERS Global filter map. * * @api */ public static $FILTERS = null; /** * Initialize global filter map. * * Called internally by LuigiTemplate. * * @internal * * @return void */ public static function init() : void { # prevent double initialization if (self::$FILTERS !== null) return; self::$FILTERS = [ 'h' => function($v) { return htmlspecialchars($v, ENT_QUOTES); }, 'u' => function($v) { return urlencode($v); }, 'json' => function($v) { return json_encode($v); }, 'hash' => function($v, $args) { if (count($args) !== 1) { throw new MissingFilterParameterError('hash'); } return hash($args[0], $v); }, 'base64' => function($v) { return base64_encode($v); }, 'nl2br' => function($v) { return nl2br($v); }, 'uc' => function($v) { return strtoupper($v); }, 'lc' => function($v) { return strtolower($v); }, 'trim' => function($v) { return trim($v); }, 'rtrim' => function($v) { return rtrim($v); }, 'ltrim' => function($v) { return ltrim($v); }, 's' => function($v) { return ($v == 1) ? '' : 's'; }, 'strlen' => function($v) { return '' . strlen($v); }, 'count' => function($v) { return '' . count($v); }, 'key' => function($v, $args) { if (count($args) !== 1) { throw new MissingFilterParameterError('key'); } # get key $key = $args[0]; # make sure key exists if (!isset($v[$key])) { throw new UnknownKeyError($key); } # return key return $v[$key]; }, ]; } }; # initialize filters Filters::init(); /** * Template object. * * Parse a template string into a Template instance, and then apply the * Template via the Template#run() method. * * Example: * * # load template class * use \Pablotron\Luigi\Template; * * # create template * $tmpl = new Template('hello %{name}'); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello Paul" * * You can also filter values in templates, using the pipe symbol: * * # create template that converts name to upper-case * $tmpl = new Template('hello %{name | uc}'); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello PAUL" * * Filters can be chained: * * # create template that converts name to upper-case and then * # strips leading and trailing whitespace * $tmpl = new Template('hello %{name | uc | trim}'); * * # run template and print result * echo $tmpl->run([ * 'name' => ' Paul ', * ]) . "\n"; * * # prints "hello PAUL" * * Filters can take arguments: * * # create template that converts name to lowercase and then * # calculates the SHA-1 digest of the result * $tmpl = new Template('hello %{name | lc | hash sha1}'); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello a027184a55211cd23e3f3094f1fdc728df5e0500" * * You can define custom global filters: * * # load template and filter classes * use \Pablotron\Luigi\Template; * use \Pablotron\Luigi\Filters; * * # create custom global filter named 'foobarify' * Filters::$FILTERS['foobarify'] = function($s) { * return "foo-{$s}-bar"; * }; * * # create template that converts name to lowercase and then * # calculates the SHA-1 digest of the result * $tmpl = new Template('hello %{name | foobarify}'); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello foo-Paul-bar" * * Or define custom filters for a template: * * # load template and filter classes * use \Pablotron\Luigi\Template; * * # create template that converts name to lowercase and then * # calculates the SHA-1 digest of the result * $tmpl = new Template('hello %{name | reverse}', [); * 'reverse' => function($s) { * return strrev($s); * }, * ]); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello luaP" * * Your custom filters can accept arguments, too: * * # load template and filter classes * use \Pablotron\Luigi\Template; * use \Pablotron\Luigi\Filters; * * # create custom global filter named 'foobarify' * Filters::$FILTERS['wrap'] = function($s, $args) { * if (count($args) == 2) { * return sprintf('(%s, %s, %s)', $args[0], $s, $args[1]); * } else if (count($args) == 1) { * return sprintf('(%s in %s)', %s, $args[0]); * } else if (count($args) == 0) { * return $s; * } else { * throw new Exception("invalid argument count"); * } * }; * * # create template which uses custom "wrap" filter" * $tmpl = new Template('sandwich: %{meat | wrap slice heel}, taco: %{meat | wrap shell}'); * * # run template and print result * echo $tmpl->run([ * 'meat' => 'chicken', * ]) . "\n"; * * # prints "sandwich: (slice, chicken, heel), taco: (chicken in shell)" * * @api * */ final class Template { /** @var string $template Input template string. */ public $template; /** * @var array $filters Filter map. * @var array $tokens Parsed template tokens. */ private $filters, $tokens; /** * Create a new Template object. * * Parse a template string into tokens. * * Example: * * # load template class * use \Pablotron\Luigi\Template; * * # create template * $tmpl = new Template('hello %{name}'); * * @api * * @param string $template Template string. * @param array $custom_filters Custom filter map (optional). */ public function __construct( string $template, array $custom_filters = [] ) { $this->template = $template; $this->filters = (count($custom_filters) > 0) ? $custom_filters : Filters::$FILTERS; # parse template into list of tokens $this->tokens = parse_template($template); } /** * Apply given arguments to template and return the result as a * string. * * Example: * * # load template class * use \Pablotron\Luigi\Template; * * # create template * $tmpl = new Template('hello %{name}'); * * # run template and print result * echo $tmpl->run([ * 'name' => 'Paul', * ]) . "\n"; * * # prints "hello Paul" * * @api * * @param array $args Template arguments. * * @return string Expanded template. * * @throws UnknownKeyError If a referenced key is missing from $args. */ public function run(array $args = []) : string { # create run context $ctx = new RunContext($args, $this->filters); return join('', array_map(function($token) use (&$ctx) { return $token->run($ctx); }, $this->tokens)); } /** * Return the input template string. * * Example: * * # load template class * use \Pablotron\Luigi\Template; * * # create template * $tmpl = new Template('hello %{name}'); * * # print template string * echo $tmpl . "\n"; * * # prints "hello %{name}" * * @api * * @return string Input template string. */ public function __toString() : string { return $this->template; } /** * Parse template string, expand it using given arguments, and return * the result as a string. * * Example: * * # load template class * use \Pablotron\Luigi\Template; * * # create template, run it, and print result * echo Template::once('foo-%{some-key}-bar', [ * 'some-key' => 'example', * ]) . "\n"; * * # prints "foo-example-bar" * @api * * @param string $template Template string. * @param array $args Template arguments. * @param array $filters Custom filter map (optional). * * @return string Expanded template. */ public static function once( string $template, array $args = [], array $filters = [] ) : string { $t = new Template($template, $filters); return $t->run($args); } }; /** * Lazy-loading template cache. * * Group a set of templates together and only parse them on an as-needed * basis. * * @api */ final class Cache implements \ArrayAccess { /** * @var array $templates Map of keys to template strings. * @var array $filters Filter map (optional). * @var array $lut Parsed template cache. */ private $templates, $filters, $lut = []; /** * Create a new template cache. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create template cache * $cache = new Cache([ * 'hi' => 'hi %{name}!', * ]); * * @api * * @param array $templates Map of template keys to template strings. * @param array $filters Custom filter map (optional). */ public function __construct(array $templates, array $filters = []) { $this->templates = $templates; $this->filters = (count($filters) > 0) ? $filters : Filters::$FILTERS; } /** * Returns true if the given template key exists in this cache. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create cache * $cache = new Cache([ * 'hi' => 'hi %{name}!', * ]); * * # get template 'hi' from cache * foreach (array('hi', 'nope') as $tmpl_key) { * echo "$key: " . (isset($cache[$key]) ? 'Yes' : 'No') . "\n" * } * * # prints "hi: Yes" and "nope: No" * @api * * @param string $key Template key. * * @return bool Returns true if the template key exists. */ public function offsetExists($key) : bool { return isset($this->templates[$key]); } /** * Get the template associated with the given template key. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create cache * $cache = new Cache([ * 'hi' => 'hi %{name}!', * ]); * * # get template 'hi' from cache * $tmpl = $cache['hi']; * * echo $tmpl->run([ * 'name' => 'Paul', * ]); * * # prints "hi Paul!" * * @api * * @param string $key Template key. * * @return Template Returns template associated with this key. * * @throws UnknownTemplateError if the given template does not exist. */ public function offsetGet($key) : Template { if (isset($this->lut[$key])) { return $this->lut[$key]; } else if (isset($this->templates[$key])) { $this->lut[$key] = new Template($this->templates[$key], $this->filters); return $this->lut[$key]; } else { throw new UnknownTemplateError($key); } } /** * Remove the template associated with the given template key. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create cache * $cache = new Cache([ * 'hi' => 'hi %{name}!', * ]); * * # remove template 'hi' from cache * unset($cache['hi']); * * echo $cache['hi']->run([ * 'name' => 'Paul', * ]); * * # throws UnknownTemplateError * * @api * * @param array $key Template key. * * @return void */ public function offsetUnset($key) : void { unset($this->lut[$key]); unset($this->templates[$key]); } /** * Set the template associated with the given template key. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create cache * $cache = new Cache(); * * # add template to cache as 'hash-name' * $cache['hash-name'] = 'hashed name: %{name | hash sha1}'; * * echo $cache['hash-name']->run([ * 'name' => 'Paul', * ]); * * # prints "sha1 of name: c3687ab9880c26dfe7ab966a8a1701b5e017c2ff" * * @api * * @param string $key Template key. * @param string $val Template string. * * @return void */ public function offsetSet($key, $val) : void { unset($this->lut[$key]); $this->templates[$key] = $val; } /** * Run template with the given arguments and return the result. * * Example: * * # load cache class * use \Pablotron\Luigi\Cache; * * # create cache * $cache = new Cache([ * 'my-template' => 'hello %{name | uc}', * ]); * * # run template * echo $cache->run('my-template', [ * 'name' => 'Paul', * ]); * * # prints "hello PAUL" * * @api * * @param string $key Template key. * @param array $args Template arguments. * * @return string Returns the expanded template result. */ public function run(string $key, array $args = []) : string { return $this->offsetGet($key)->run($args); } };