* <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> * * * The variables of the form *{$variable}* that are used in the template * above, are examples of expressions. An expression is always enclosed in * curly brackets: *{expression}*. The grammar of all expressions that are * currently supported can be described as follows: * * <expression> : {<exp>} * <exp> : <nested_exp> * | <nested_exp>?<nested_exp>:<nested_exp> # Conditional statement * <nested_exp> : * | <variable> * | <nested_exp>||<nested_exp> # Default value * | <function>(<nested_exp>) # Static function call * | <constant> * | <html> * <variable> : $<name> # Regular variable (escaped) * | $<name>.<name> # Object attribute or associative array value (escaped) * | $<name>.<name>() # Method call (escaped) (no arguments allowed) * | $$<name> # Regular variable (plain) * | $$<name>.<name> # Object attribute or associative array value (plain) * | $$<name>.<name>() # Method call (plain) * <function> : <name> # Global function * | <name>::<name> # Static class method * <constant> : An all-caps PHP constant: [A-Z0-9_]+ * <html> : A string without parentheses, pipes, curly brackets or semicolons: [^()|{}:]* * <name> : A non-empty variable/method name consisting of [a-zA-Z0-9-_]+ * * * @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 $html = $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); } elseif (strpos($brackets_content, "\n") !== false) { // Bracket content contains newlines, so it is probably JavaScript or CSS $html->set('content', $before . '{' . $brackets_content . '}'); } else { // Variable or something else $current->add('expression')->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 $after && $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 evaluate_expression() */ 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 'expression': $html .= self::evaluate_expression($child->get('content'), $data); } } return $html; } /** * Evaluate a expression. * * This function is a helper for {@link evaluate_expression()}. * * @param string[] $matches Regex matches for variable pattern. * @return string The evaluation of the variable. * @param Node $data A data tree containing variable values to use. * @throws \BadMethodCallException If an error occured while calling a variable method. * @throws \OutOfBoundsException If an unexisting array key is requested. * @throws \UnexpectedValueException In some other error situations. */ private static function evaluate_variable(array $matches, Node $data) { $before = $matches[1]; $noescape_sign = $matches[2]; $variable = $matches[3]; $value = $data->get($variable); if (count($matches) == 5) { // $. $attribute = $matches[4]; if ($value === null) { throw new \UnexpectedValueException( sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute) ); } $attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) { throw new $class( sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error) ); }; if (is_array($value)) { isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException'); $value = $value[$attribute]; } elseif (is_object($value)) { isset($value->$attribute) || $attr_error('no such attribute'); $value = $value->$attribute; } else { $attr_error('variable is no array or object'); } } elseif (count($matches) == 6) { // $.() $method = $matches[4]; if ($value === null) { throw new \UnexpectedValueException( sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method) ); } $method_error = function($error) use ($method, $variable) { throw new \BadMethodCallException( sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error) ); }; if (is_object($value)) { method_exists($value, $method) || $method_error('no such method'); $value = $value->$method(); } else { $method_error('variable is no object'); } } // Escape value if (is_string($value) && !$noescape_sign) $value = self::escape_variable_value($value); return $before . $value; } /** * Escape a variable value for displaying in HTML. * * Uses {@link http://php.net/htmlentities} with ENT_QUOTES. * * @param string $value The variable value to escape. * @return string The escaped value. */ private static function escape_variable_value($value) { return htmlspecialchars($value, ENT_QUOTES); } /** * Evaluate a conditional expression. * * This function is a helper for {@link evaluate_expression()}. * * @param string[] $matches Regex matches for conditional pattern. * @param Node $data A data tree containing variable values to use for * variable expressions. * @return string The evaluation of the condition. */ private static function evaluate_condition(array $matches, Node $data) { if (self::evaluate_expression($matches[1], $data, false)) { // Condition evaluates to true: return 'if' evaluation return self::evaluate_expression($matches[2], $data, false); } elseif (count($matches) == 4) { // ?: return self::evaluate_expression($matches[3], $data, false); } // No 'else' specified: evaluation is an empty string return ''; } /** * Evaluate a static function call expression. * * This function is a helper for {@link evaluate_expression()}. * * @param array $matches Regex matches for function pattern. * @param Node $data A data tree containing variable values to use for * variable expressions. * @return string The evaluation of the function call. * @throws \BadFunctionCallException If the function is undefined. */ private static function evaluate_function(array $matches, Node $data) { $function = $matches[1]; $parameter = $matches[2]; if (!is_callable($function)) { throw new \BadFunctionCallException( sprintf('Cannot call function "%s": function is not callable', $function) ); } $parameter_value = self::evaluate_expression($parameter, $data, false); return call_user_func($function, $parameter_value); } /** * Evaluate a PHP-constant expression. * * This function is a helper for {@link evaluate_expression()}. * * @param string $constant The name of the PHP constant. * @param bool $root_level Whether the expression was enclosed in curly * brackets (FALSE for sub-expressions); * @return string The evaluation of the constant if it is defined, the * original constant name otherwise. */ private static function evaluate_constant($constant, $root_level) { if (defined($constant)) return constant($constant); return $root_level ? '{' . $constant . '}' : $constant; } /** * Evaluate an expression. * * The curly brackets should already have been stripped before passing an * expression to this method. * * @param string $expression The expression to evaluate. * @param Node $data A data tree containing variable values to use for * variable expressions. * @param bool $root_level Whether the expression was enclosed in curly * brackets (FALSE for sub-expressions); * @return string The evaluation of the expression if present, the * original string enclosed in curly brackets otherwise. */ private static function evaluate_expression($expression, Node $data, $root_level=true) { if ($expression) { $name = '[a-zA-Z0-9-_]+'; $function = "$name(?:::$name)?"; if (preg_match("/^([^?]*?)\s*\?([^:]*)(?::(.*))?$/", $expression, $matches)) { // ? | ?: return self::evaluate_condition($matches, $data); } elseif (preg_match("/^(.*?)\\$(\\$?)($name)(?:\.($name)(\(\))?)?$/", $expression, $matches)) { // $ | $. | $.() // | $$ | $$. | $$.() return self::evaluate_variable($matches, $data); } elseif (preg_match("/^($function)\((.+?)\)?$/", $expression, $matches)) { // () return self::evaluate_function($matches, $data); } elseif (preg_match("/^([A-Z0-9_]+)$/", $expression, $matches)) { // return self::evaluate_constant($expression, $root_level); } elseif (($split_at = strpos($expression, '||', 1)) !== false) { // || try { return self::evaluate_expression(substr($expression, 0, $split_at), $data, false); } catch(\RuntimeException $e) { return self::evaluate_expression(substr($expression, $split_at + 2), $data, false); } } } // No expression: return original string return $root_level ? '{' . $expression . '}' : $expression; } /** * 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); } } ?>