template.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. <?php
  2. /**
  3. * HTML template rendering functions.
  4. *
  5. * @author Taddeus Kroes
  6. * @date 14-07-2012
  7. */
  8. namespace WebBasics;
  9. require_once 'node.php';
  10. /**
  11. * A Template object represents a template file.
  12. *
  13. * A template file contains 'blocks' that can be rendered zero or more times.
  14. * Each block has a set of properties that can be accessed using curly
  15. * brackets ('{' and '}'). Curly brackets may contain macro's to minimize
  16. * common view logic in controllers.
  17. *
  18. * Example template 'page.tpl':
  19. * <code>
  20. * &lt;html&gt;
  21. * &lt;head&gt;
  22. * &lt;title&gt;{$page_title}&lt;/title&gt;
  23. * &lt;/head&gt;
  24. * &lt;body&gt;
  25. * &lt;h1&gt;{$page_title}&lt;/h1&gt;
  26. * &lt;div id="content"&gt;{$page_content}&lt;/div&gt;
  27. * &lt;div id="ads"&gt;
  28. * {block:ad}
  29. * &lt;div class="ad"&gt;{$ad_content}&lt;/div&gt;
  30. * {end}
  31. * &lt;/div&gt;
  32. * &lt;/body&gt;
  33. * &lt;/html&gt;
  34. * </code>
  35. * And the corresponding PHP code:
  36. * <code>
  37. * $tpl = new Template('page');
  38. * $tpl->set(array(
  39. * 'page_title' => 'Some title',
  40. * 'page_content' => 'Lorem ipsum ...'
  41. * ));
  42. *
  43. * foreach( array('Some ad', 'Another ad', 'More ads') as $ad )
  44. * $tpl->add('ad')->set('ad_content', $ad);
  45. *
  46. * echo $tpl->render();
  47. * </code>
  48. * The output will be:
  49. * <code>
  50. * &lt;html&gt;
  51. * &lt;head&gt;
  52. * &lt;title&gt;Some title&lt;/title&gt;
  53. * &lt;/head&gt;
  54. * &lt;body&gt;
  55. * &lt;h1&gt;Some title&lt;/h1&gt;
  56. * &lt;div id="content"&gt;Some content&lt;/div&gt;
  57. * &lt;div id="ads"&gt;
  58. * &lt;div class="ad"&gt;Some ad&lt;/div&gt;
  59. * &lt;div class="ad"&gt;Another ad&lt;/div&gt;
  60. * &lt;div class="ad"&gt;More ads&lt;/div&gt;
  61. * &lt;/div&gt;
  62. * &lt;/body&gt;
  63. * &lt;/html&gt;
  64. * </code>
  65. *
  66. * The variables of the form *{$variable}* that are used in the template
  67. * above, are examples of expressions. An expression is always enclosed in
  68. * curly brackets: *{expression}*. The grammar of all expressions that are
  69. * currently supported can be described as follows:
  70. * <code>
  71. * &lt;expression&gt; : {&lt;exp&gt;}
  72. * &lt;exp&gt; : &lt;nested_exp&gt;
  73. * | &lt;nested_exp&gt;?&lt;nested_exp&gt;:&lt;nested_exp&gt; # Conditional statement
  74. * &lt;nested_exp&gt; : &lt;variable&gt;
  75. * | &lt;nested_exp&gt;||&lt;nested_exp&gt; # Default value
  76. * | &lt;function&gt;(&lt;nested_exp&gt;) # Static function call
  77. * | &lt;constant&gt;
  78. * | &lt;html&gt;
  79. * &lt;variable&gt; : $&lt;name&gt; # Regular variable
  80. * | $&lt;name&gt;.&lt;name&gt; # Object attribute or associative array value
  81. * | $&lt;name&gt;.&lt;name&gt;() # Method call (no arguments allowed)
  82. * &lt;function&gt; : &lt;name&gt; # Global function
  83. * | &lt;name&gt;::&lt;name&gt; # Static class method
  84. * &lt;constant&gt; : An all-caps PHP constant: [A-Z0-9_]+
  85. * &lt;html&gt; : A string without parentheses, pipes, curly brackets or semicolons: [^()|{}:]*
  86. * &lt;name&gt; : A non-empty variable/method name consisting of [a-zA-Z0-9-_]+
  87. * </code>
  88. *
  89. * @package WebBasics
  90. */
  91. class Template extends Node {
  92. /**
  93. * Default extension of template files.
  94. *
  95. * @var array
  96. */
  97. const DEFAULT_EXTENSION = '.tpl';
  98. /**
  99. * Root directories from which template files are included.
  100. *
  101. * @var array
  102. */
  103. private static $include_path = array();
  104. /**
  105. * The path the template was found in.
  106. *
  107. * @var string
  108. */
  109. private $path;
  110. /**
  111. * The content of the template file.
  112. *
  113. * @var string
  114. */
  115. private $file_content;
  116. /**
  117. * The block structure of the template file.
  118. *
  119. * @var Node
  120. */
  121. private $root_block;
  122. /**
  123. * Create a new Template object, representing a template file.
  124. *
  125. * Template files are assumed to have the .tpl extension. If no extension
  126. * is specified, '.tpl' is appended to the filename.
  127. *
  128. * @param string $filename The path to the template file from one of the root directories.
  129. */
  130. function __construct($filename) {
  131. // Add default extension if none is found
  132. strpos($filename, '.') === false && $filename .= self::DEFAULT_EXTENSION;
  133. $look_in = count(self::$include_path) ? self::$include_path : array('.');
  134. $found = false;
  135. foreach( $look_in as $root ) {
  136. $path = $root.$filename;
  137. if( file_exists($path) ) {
  138. $this->path = $path;
  139. $this->file_content = file_get_contents($path);
  140. $found = true;
  141. break;
  142. }
  143. }
  144. if( !$found ) {
  145. throw new \RuntimeException(
  146. sprintf("Could not find template file \"%s\", looked in folders:\n%s",
  147. $filename, implode("\n", $look_in))
  148. );
  149. }
  150. $this->parse_blocks();
  151. }
  152. /**
  153. * Get the path to the template file (including one of the include paths).
  154. *
  155. * @return string The path to the template file.
  156. */
  157. function get_path() {
  158. return $this->path;
  159. }
  160. /**
  161. * Parse the content of the template file into a tree structure of blocks
  162. * and variables.
  163. *
  164. * @throws ParseError If an {end} tag is not used properly.
  165. */
  166. private function parse_blocks() {
  167. $current = $root = new Node('block');
  168. $after = $this->file_content;
  169. $line_count = 0;
  170. while( preg_match('/(.*?)\{([^}]+)}(.*)/s', $after, $matches) ) {
  171. list($before, $brackets_content, $after) = array_slice($matches, 1);
  172. $line_count += substr_count($before, "\n");
  173. // Everything before the new block belongs to its parent
  174. $current->add('html')->set('content', $before);
  175. if( $brackets_content == 'end' ) {
  176. // {end} encountered, go one level up in the tree
  177. if( $current->is_root() )
  178. throw new ParseError($this, 'unexpected {end}', $line_count + 1);
  179. $current = $current->get_parent();
  180. } elseif( substr($brackets_content, 0, 6) == 'block:' ) {
  181. // {block:...} encountered
  182. $block_name = substr($brackets_content, 6);
  183. // Go one level deeper into the tree
  184. $current = $current->add('block')->set('name', $block_name);
  185. } else {
  186. // Variable or something else
  187. $current->add('expression')->set('content', $brackets_content);
  188. }
  189. }
  190. $line_count += substr_count($after, "\n");
  191. if( $current !== $root )
  192. throw new ParseError($this, 'missing {end}', $line_count + 1);
  193. // Add the last remaining content to the root node
  194. $root->add('html')->set('content', $after);
  195. $this->root_block = $root;
  196. }
  197. /**
  198. * Replace blocks and variables in the template's content.
  199. *
  200. * @return string The template's content, with replaced blocks and variables.
  201. */
  202. function render() {
  203. // Use recursion to parse all blocks from the root level
  204. return self::render_block($this->root_block, $this);
  205. }
  206. /**
  207. * Render a single block, recursively parsing its sub-blocks with a given data scope.
  208. *
  209. * @param Node $block The block to render.
  210. * @param Node $data The data block to search in for the variable values.
  211. * @return string The rendered block.
  212. * @uses evaluate_expression()
  213. */
  214. private static function render_block(Node $block, Node $data) {
  215. $html = '';
  216. foreach( $block->get_children() as $child ) {
  217. switch( $child->get_name() ) {
  218. case 'html':
  219. $html .= $child->get('content');
  220. break;
  221. case 'block':
  222. $block_name = $child->get('name');
  223. foreach( $data->find($block_name) as $child_data )
  224. $html .= self::render_block($child, $child_data);
  225. break;
  226. case 'expression':
  227. $html .= self::evaluate_expression($child->get('content'), $data);
  228. }
  229. }
  230. return $html;
  231. }
  232. /**
  233. * Evaluate a <variable> expression.
  234. *
  235. * This function is a helper for {@link evaluate_expression()}.
  236. *
  237. * @param array $matches Regex matches for variable pattern.
  238. * @return string The evaluation of the variable.
  239. * @param Node $data A data tree containing variable values to use.
  240. * @throws \BadMethodCallException If an error occured while calling a variable method.
  241. * @throws \OutOfBoundsException If an unexisting array key is requested.
  242. * @throws \UnexpectedValueException In some other error situations.
  243. */
  244. private static function evaluate_variable(array $matches, Node $data) {
  245. $variable = $matches[1];
  246. $value = $data->get($variable);
  247. if( count($matches) == 3 ) {
  248. // $<name>.<name>
  249. $attribute = $matches[2];
  250. if( $value === null ) {
  251. throw new \UnexpectedValueException(
  252. sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute)
  253. );
  254. }
  255. $attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) {
  256. throw new $class(
  257. sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error)
  258. );
  259. };
  260. if( is_array($value) ) {
  261. isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException');
  262. $value = $value[$attribute];
  263. } elseif( is_object($value) ) {
  264. isset($value->$attribute) || $attr_error('no such attribute');
  265. $value = $value->$attribute;
  266. } else {
  267. $attr_error('variable is no array or object');
  268. }
  269. } elseif( count($matches) == 4 ) {
  270. // $<name>.<name>()
  271. $method = $matches[2];
  272. if( $value === null ) {
  273. throw new \UnexpectedValueException(
  274. sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method)
  275. );
  276. }
  277. $method_error = function($error) use ($method, $variable) {
  278. throw new \BadMethodCallException(
  279. sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error)
  280. );
  281. };
  282. if( is_object($value) ) {
  283. method_exists($value, $method) || $method_error('no such method');
  284. $value = $value->$method();
  285. } else {
  286. $method_error('variable is no object');
  287. }
  288. }
  289. return $value;
  290. }
  291. /**
  292. * Evaluate a conditional expression.
  293. *
  294. * This function is a helper for {@link evaluate_expression()}.
  295. *
  296. * @param array $matches Regex matches for conditional pattern.
  297. * @param Node $data A data tree containing variable values to use for
  298. * variable expressions.
  299. * @return string The evaluation of the condition.
  300. */
  301. private static function evaluate_condition(array $matches, Node $data) {
  302. if( self::evaluate_expression($matches[1], $data, false) ) {
  303. // Condition evaluates to true: return 'if' evaluation
  304. return self::evaluate_expression($matches[2], $data, false);
  305. } elseif( count($matches) == 4 ) {
  306. // <nested_exp>?<nested_exp>:<nested_exp>
  307. return self::evaluate_expression($matches[3], $data, false);
  308. }
  309. // No 'else' specified: evaluation is an empty string
  310. return '';
  311. }
  312. /**
  313. * Evaluate a static function call expression.
  314. *
  315. * This function is a helper for {@link evaluate_expression()}.
  316. *
  317. * @param array $matches Regex matches for function pattern.
  318. * @param Node $data A data tree containing variable values to use for
  319. * variable expressions.
  320. * @return string The evaluation of the function call.
  321. * @throws \BadFunctionCallException If the function is undefined.
  322. */
  323. private static function evaluate_function(array $matches, Node $data) {
  324. $function = $matches[1];
  325. $parameter = $matches[2];
  326. if( !is_callable($function) ) {
  327. throw new \BadFunctionCallException(
  328. sprintf('Cannot call function "%s": function is not callable', $function)
  329. );
  330. }
  331. $parameter_value = self::evaluate_expression($parameter, $data, false);
  332. return call_user_func($function, $parameter_value);
  333. }
  334. /**
  335. * Evaluate a PHP-constant expression.
  336. *
  337. * This function is a helper for {@link evaluate_expression()}.
  338. *
  339. * @param string $constant The name of the PHP constant.
  340. * @param bool $root_level Whether the expression was enclosed in curly
  341. * brackets (FALSE for sub-expressions);
  342. * @return string The evaluation of the constant if it is defined, the
  343. * original constant name otherwise.
  344. */
  345. private static function evaluate_constant($constant, $root_level) {
  346. if( defined($constant) )
  347. return constant($constant);
  348. return $root_level ? '{' . $constant . '}' : $constant;
  349. }
  350. /**
  351. * Evaluate an expression.
  352. *
  353. * The curly brackets should already have been stripped before passing an
  354. * expression to this method.
  355. *
  356. * @param string $expression The expression to evaluate.
  357. * @param Node $data A data tree containing variable values to use for
  358. * variable expressions.
  359. * @param bool $root_level Whether the expression was enclosed in curly
  360. * brackets (FALSE for sub-expressions);
  361. * @return string The evaluation of the expression if present, the
  362. * original string enclosed in curly brackets otherwise.
  363. */
  364. private static function evaluate_expression($expression, Node $data, $root_level=true) {
  365. if( $expression ) {
  366. $name = '[a-zA-Z0-9-_]+';
  367. $function = "$name(?:::$name)?";
  368. if( preg_match("/^([^?]*?)\s*\?([^:]*)(?::(.*))?$/", $expression, $matches) ) {
  369. // <nested_exp>?<nested_exp> | <nested_exp>?<nested_exp>:<nested_exp>
  370. return self::evaluate_condition($matches, $data);
  371. } elseif( preg_match("/^\\$($name)(?:\.($name)(\(\))?)?$/", $expression, $matches) ) {
  372. // $<name> | $<name>.<name> | $<name>.<name>()
  373. return self::evaluate_variable($matches, $data);
  374. } elseif( preg_match("/^($function)\((.+?)\)?$/", $expression, $matches) ) {
  375. // <function>(<nested_exp>)
  376. return self::evaluate_function($matches, $data);
  377. } elseif( preg_match("/^([A-Z0-9_]+)$/", $expression, $matches) ) {
  378. // <constant>
  379. return self::evaluate_constant($expression, $root_level);
  380. } elseif( ($split_at = strpos($expression, '||', 1)) !== false ) {
  381. // <nested_exp>||<nested_exp>
  382. try {
  383. return self::evaluate_expression(substr($expression, 0, $split_at), $data, false);
  384. } catch(\RuntimeException $e) {
  385. return self::evaluate_expression(substr($expression, $split_at + 2), $data, false);
  386. }
  387. }
  388. }
  389. // No expression: return original string
  390. return $root_level ? '{' . $expression . '}' : $expression;
  391. }
  392. /**
  393. * Remove all current include paths.
  394. */
  395. static function clear_include_path() {
  396. self::$include_path = array();
  397. }
  398. /**
  399. * Replace all include paths by a single new one.
  400. *
  401. * @param string $path The new path to set as root.
  402. * @uses clear_include_path()
  403. */
  404. static function set_root($path) {
  405. self::clear_include_path();
  406. self::add_root($path);
  407. }
  408. /**
  409. * Add a new include path.
  410. *
  411. * @param string $path The path to add.
  412. * @throws FileNotFoundError If the path does not exist.
  413. */
  414. static function add_root($path) {
  415. if( $path[strlen($path) - 1] != '/' )
  416. $path .= '/';
  417. if( !is_dir($path) )
  418. throw new FileNotFoundError($path, true);
  419. self::$include_path[] = $path;
  420. }
  421. }
  422. /**
  423. * Error, thrown when an error occurs during the parsing of a template file.
  424. *
  425. * @package WebBasics
  426. */
  427. class ParseError extends \RuntimeException {
  428. /**
  429. * Constructor.
  430. *
  431. * Sets an error message with the path to the template file and a line number.
  432. *
  433. * @param Template $tpl The template in which the error occurred.
  434. * @param string $message A message describing the error.
  435. * @param int $line The line number at which the error occurred.
  436. */
  437. function __construct(Template $tpl, $message, $line) {
  438. $this->message = sprintf('Parse error in file %s, line %d: %s',
  439. $tpl->get_path(), $line, $message);
  440. }
  441. }
  442. ?>