* <html>
* <head>
* <title>{page_title}</title>
* </head>
* <body>
* <h1>{page_title}</h1>
* <div id="content">{page_content}</div>
* <div id="ads">
* {block:ad}
* <div class="ad">{ad_content}</div>
* {end}
* </div>
* </body>
* </html>
*
* And the corresponding PHP code:
*
* $tpl = new Template('page');
* $tpl->set(array(
* 'page_title' => 'Some title',
* 'page_content' => 'Lorem ipsum ...'
* ));
*
* foreach( array('Some ad', 'Another ad', 'More ads') as $ad )
* $tpl->add('ad')->set('ad_content', $ad);
*
* echo $tpl->render();
*
* The output will be:
*
* <html>
* <head>
* <title>Some title</title>
* </head>
* <body>
* <h1>Some title</h1>
* <div id="content">Some content</div>
* <div id="ads">
* <div class="ad">Some ad</div>
* <div class="ad">Another ad</div>
* <div class="ad">More ads</div>
* </div>
* </body>
* </html>
*
*
* @package WebBasics
*/
class Template extends Node {
/**
* Default extension of template files.
*
* @var array
*/
const DEFAULT_EXTENSION = '.tpl';
/**
* Root directories from which template files are included.
*
* @var array
*/
private static $include_path = array();
/**
* The path the template was found in.
*
* @var string
*/
private $path;
/**
* The content of the template file.
*
* @var string
*/
private $file_content;
/**
* The block structure of the template file.
*
* @var Node
*/
private $root_block;
/**
* Create a new Template object, representing a template file.
*
* Template files are assumed to have the .tpl extension. If no extension
* is specified, '.tpl' is appended to the filename.
*
* @param string $filename The path to the template file from one of the root directories.
*/
function __construct($filename) {
// Add default extension if none is found
strpos($filename, '.') === false && $filename .= self::DEFAULT_EXTENSION;
$look_in = count(self::$include_path) ? self::$include_path : array('.');
$found = false;
foreach( $look_in as $root ) {
$path = $root.$filename;
if( file_exists($path) ) {
$this->path = $path;
$this->file_content = file_get_contents($path);
$found = true;
break;
}
}
if( !$found ) {
throw new \RuntimeException(
sprintf("Could not find template file \"%s\", looked in folders:\n%s",
$filename, implode("\n", $look_in))
);
}
$this->parse_blocks();
}
/**
* Get the path to the template file (including one of the include paths).
*
* @return string The path to the template file.
*/
function get_path() {
return $this->path;
}
/**
* Parse the content of the template file into a tree structure of blocks
* and variables.
*
* @throws ParseError If an {end} tag is not used properly.
*/
private function parse_blocks() {
$current = $root = new Node('block');
$after = $this->file_content;
$line_count = 0;
while( preg_match('/(.*?)\{([^}]+)}(.*)/s', $after, $matches) ) {
list($before, $brackets_content, $after) = array_slice($matches, 1);
$line_count += substr_count($before, "\n");
// Everything before the new block belongs to its parent
$current->add('html')->set('content', $before);
if( $brackets_content == 'end' ) {
// {end} encountered, go one level up in the tree
if( $current->is_root() )
throw new ParseError($this, 'unexpected {end}', $line_count + 1);
$current = $current->get_parent();
} elseif( substr($brackets_content, 0, 6) == 'block:' ) {
// {block:...} encountered
$block_name = substr($brackets_content, 6);
// Go one level deeper into the tree
$current = $current->add('block')->set('name', $block_name);
} else {
// Variable or something else
$current->add('variable')->set('content', $brackets_content);
}
}
$line_count += substr_count($after, "\n");
if( $current !== $root )
throw new ParseError($this, 'missing {end}', $line_count + 1);
// Add the last remaining content to the root node
$root->add('html')->set('content', $after);
$this->root_block = $root;
}
/**
* Replace blocks and variables in the template's content.
*
* @return string The template's content, with replaced blocks and variables.
*/
function render() {
// Use recursion to parse all blocks from the root level
return self::render_block($this->root_block, $this);
}
/**
* Render a single block, recursively parsing its sub-blocks with a given data scope.
*
* @param Node $block The block to render.
* @param Node $data The data block to search in for the variable values.
* @return string The rendered block.
* @uses replace_variable()
*/
private static function render_block(Node $block, Node $data) {
$html = '';
foreach( $block->get_children() as $child ) {
switch( $child->get_name() ) {
case 'html':
$html .= $child->get('content');
break;
case 'block':
$block_name = $child->get('name');
foreach( $data->find($block_name) as $child_data )
$html .= self::render_block($child, $child_data);
break;
case 'variable':
$html .= self::replace_variable($child->get('content'), $data);
}
}
return $html;
}
/**
* Replace a variable name if it exists within a given data scope.
*
* Applies any of the following macro's:
*
* --------
* Variable
* --------
* {var_name[:func1:func2:...]}
* *var_name* can be of the form *foo.bar*. In this case, *foo* is the
* name of an object or associative array variable. *bar* is a property
* name to get of the object, or the associative index to the array.
* *func1*, *func2*, etc. are helper functions that are executed in the
* same order as listed. The retuen value of each helper function replaces
* the previous variable value. *var_name* Can also be the name of a
* defined constant.
*
* ------------
* If-statement
* ------------
* {if:condition:success_variable[:else:failure_variable]}
* *condition* is evaluated to a boolean. If it evaluates to TRUE, the
* value of *success_variable* is used. Otherwise, the value of
* *failure_variable* is used (defaults to an empty string if no
* else-statement is specified).
*
* @param string $variable The variable to replace.
* @param Node $data The data block to search in for a value.
* @return string The variable's value if it exists, the original string
* with curly brackets otherwise.
* @throws \UnexpectedValueException If a helper function is not callable.
*/
private static function replace_variable($variable, Node $data) {
// If-(else-)statement
if( preg_match('/^if:([^:]*):(.*?)(?::else:(.*))?$/', $variable, $matches) ) {
$condition = $data->get($matches[1]);
$if = $data->get($matches[2]);
if( $condition )
return $if;
return count($matches) > 3 ? self::replace_variable($matches[3], $data) : '';
}
// Default: variable with optional helper functions
$parts = explode(':', $variable);
$name = $parts[0];
$helper_functions = array_slice($parts, 1);
if( strpos($name, '.') !== false ) {
// Variable of the form 'foo.bar'
list($variable_name, $property) = explode('.', $name, 2);
$object = $data->get($variable_name);
if( is_object($object) && property_exists($object, $property) ) {
// Object property
$value = $object->$property;
} elseif( is_array($object) && isset($object[$property]) ) {
// Associative array index
$value = $object[$property];
}
}
// Default: Simple variable name
if( !isset($value) ) {
$value = $data->get($name);
if( $value === null && defined($name) )
$value = constant($name);
}
// Don't continue if the variable name is not found in the data block
if( $value !== null ) {
// Apply helper functions to the variable's value iteratively
foreach( $helper_functions as $func ) {
if( !is_callable($func) ) {
throw new \UnexpectedValueException(
sprintf('Helper function "%s" is not callable.', $func)
);
}
$value = $func($value);
}
return $value;
}
return '{'.$variable.'}';
}
/**
* Remove all current include paths.
*/
static function clear_include_path() {
self::$include_path = array();
}
/**
* Replace all include paths by a single new one.
*
* @param string $path The new path to set as root.
* @uses clear_include_path()
*/
static function set_root($path) {
self::clear_include_path();
self::add_root($path);
}
/**
* Add a new include path.
*
* @param string $path The path to add.
* @throws FileNotFoundError If the path does not exist.
*/
static function add_root($path) {
if( $path[strlen($path) - 1] != '/' )
$path .= '/';
if( !is_dir($path) )
throw new FileNotFoundError($path, true);
self::$include_path[] = $path;
}
}
/**
* Error, thrown when an error occurs during the parsing of a template file.
*
* @package WebBasics
*/
class ParseError extends \RuntimeException {
/**
* Constructor.
*
* Sets an error message with the path to the template file and a line number.
*
* @param Template $tpl The template in which the error occurred.
* @param string $message A message describing the error.
* @param int $line The line number at which the error occurred.
*/
function __construct(Template $tpl, $message, $line) {
$this->message = sprintf('Parse error in file %s, line %d: %s',
$tpl->get_path(), $line, $message);
}
}
?>