Skip to content

Instantly share code, notes, and snippets.

@gmazzap
Last active April 6, 2016 17:35
Show Gist options
  • Save gmazzap/510b731f4a1ee920b5df to your computer and use it in GitHub Desktop.
Save gmazzap/510b731f4a1ee920b5df to your computer and use it in GitHub Desktop.
Very simple, quite powerful, extensible, plain-PHP one-class template engine.
<?php namespace GM;
/**
* Angie. Very simple, and quite powerful, plain PHP template engine.
*
* @author Giuseppe Mazzapica <[email protected]>
* @license http://opensource.org/licenses/MIT MIT
*/
class Angie
{
/**
* @var array
*/
private $data;
/**
* @var bool
*/
private $strictVars;
/**
* @var bool|string
*/
private $rendering = false;
/**
* @var array
*/
private $functions = [];
/**
* @param array $data Pre-defined data
* @param bool $strictVars
*/
public function __construct(array $data = [], $strictVars = false)
{
$this->data = $data;
$this->strictVars = $strictVars;
}
/**
* Allow to call registered functions from template files.
*
* Registered functions can be called in template files using `$this->functionName()`
* or the shorter alias `$A->functionName()`
*
* @param string $name
* @param array $args
* @return mixed
* @see Angie::registerFunction()
*/
public function __call($name, array $args)
{
if ( ! $this->rendering) {
throw new \LogicException(
sprintf('%s() function only available when rendering.', $name)
);
}
$exists = array_key_exists($name, $this->functions);
if (! $exists && ! function_exists($name)) {
throw new \RuntimeException(
sprintf('%s is not a registered function.', $name)
);
}
$callable = $exists ? $this->functions[$name] : $name;
ob_start();
$return = call_user_func_array($callable, $args);
$output = ob_get_clean();
return $output ? : $return;
}
/**
* Allow to call template variables in template files.
*
* Variables not set are rendered as empty string (no need to check for isset()) unless the
* `$strictVars` object var is set to `true` ins constructor.
*
* @param string $name
* @return string
*/
public function __get($name)
{
if (! $this->rendering) {
throw new \LogicException('Variable access is only available when rendering.');
}
$exists = array_key_exists($name, $this->data);
if (! $exists && $this->strictVars) {
throw new \RuntimeException(sprintf('Unresolved var: %s.', $name));
}
return $exists ? $this->data[$name] : '';
}
/**
* Render a template with given data.
*
* Given data is merged with base data passed to constructor.
*
* Variable to be replaced with data have to be written using the syntax
* (similar to `sprintf` syntax):
*
* ```
* Hello <b>%user_name$s<b>,<br>
* your last access was <span class="last-access">%last_access_days$s ago</span>.
* ```
*
* Where the variable names (that must match `$data` keys) are:
* `user_name` and `last_access_days`.
*
* This syntax is, of course, available only for string variables.
*
* This syntax allows to write template without the need to use PHP code,
* e.g. just passing arbitrary string.
*
* However, when using template files, they're regular PHP files that may contain any PHP code.
*
* To access variables in template files is possible to use `$this->var_name`
* or the shorter alias `$A->var_name`.
*
* In the same way is possible to call Angie methods (e.g. `partial()`)
* and all the register functions.
*
* @param string $template Arbitrary or full path to a template file
* @param array $data
* @return string
*/
public function render($template = '', $data = [])
{
$baseData = $this->data;
$this->data = array_merge($this->data, $data);
$template = $this->maybeFile($template);
// replace sprintf-like vars
if (preg_match_all('~(?:^|[^\w])(%([a-zA-Z_]+)\$s)(?:\b)~m', $template, $matches)) {
$search = array_unique($matches[1]);
// muted to avoid double exception when `getReplace()` throws and exception
$replace = @array_map([$this, 'getReplace'], array_unique($matches[2]));
$template = str_replace($search, $replace, $template);
}
// reset data so additional calls to `render()` are not affected by template-specific data
$this->data = $baseData;
return $template;
}
/**
* Inside template files this method can be used to render a partial template.
*
* Given data is merged with the "parent" template data if `$isolate` argument
* is false-ish.
*
* The method can be called in template files using `$this->partial()`
* or the shorter alias `$A->partial()`.
*
* @param string $partial Full path to a template file
* @param array $data
* @param bool $isolate
* @return string
*/
public function partial($partial, array $data = [], $isolate = false)
{
if (! is_string($partial)) {
throw new \InvalidArgumentException(
sprintf('Partial path must be in a string.', $partial)
);
}
if (! $this->rendering) {
throw new \LogicException(
sprintf('%s only available when rendering.', __METHOD__)
);
}
$instance = clone $this;
$instance->data = $isolate ? $data : array_merge($this->data, $data);
// try to treat partial as relative path from the folder of the parent
if (! is_file($partial)) {
$relative = filter_var($partial, FILTER_SANITIZE_URL);
$dir = dirname($this->rendering);
$partial = $dir.DIRECTORY_SEPARATOR.ltrim($relative, '\\/.');
}
if (! is_file($partial)) {
throw new \RuntimeException(
sprintf('%s is not a valid partial template.', $partial)
);
}
return $instance->render($partial);
}
/**
* Using this method (only available outside template files) is possible
* to register arbitrary callbacks that can then be used in template files,
* and accessed with `$this->funcName()` or the shorter alias `$A->funcName()`.
*
* In this way is possible to write expressive and readable templates,
* without having to write dozens of global PHP functions or dealing
* with PHP objects and namespaces in template files.
*
* @param string $name
* @param callable $function
*/
public function registerFunction($name, callable $function)
{
if ($this->rendering) {
throw new \LogicException(
sprintf('%s only available when not rendering.', __METHOD__)
);
}
$this->functions[$name] = $function;
}
/**
* If template is a file, loads it and return the output as template.
* If template is an arbitrary string, just returns it.
*
* @param string $template
* @return string
*/
private function maybeFile($template)
{
if (is_file($template) && is_readable($template)) {
$this->rendering = $template;
// inside template files the `$A` var is available to call Angie methods
// like $A->partial()` or any of the callback registered,
// using `registerFunction()` method
$A = $this;
ob_start();
/** @noinspection PhpIncludeInspection */
require $template;
$template = ob_get_clean();
$this->rendering = false;
}
return $template;
}
/**
* Get the replacement value for variables wrote using sprintf-like syntax.
*
* @param string $var
* @return string
*/
private function getReplace($var)
{
if ($this->strictVars && ! isset($this->data[$var])) {
throw new \RuntimeException(sprintf('Var %s is not set.', $var));
}
$replace = isset($this->data[$var]) ? $this->data[$var] : '';
if (is_scalar($replace)) {
return (string) $replace;
} elseif (is_object($replace) && method_exists($replace, '__toString')) {
return $replace->__toString();
}
if ($this->strictVars) {
throw new \InvalidArgumentException(sprintf('Var %s is not a string.', $var));
}
return '';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment