template.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  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. $html = $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. } elseif( strpos($brackets_content, "\n") !== false ) {
  186. // Bracket content contains newlines, so it is probably JavaScript or CSS
  187. $html->set('content', $before . '{' . $brackets_content . '}');
  188. } else {
  189. // Variable or something else
  190. $current->add('expression')->set('content', $brackets_content);
  191. }
  192. }
  193. $line_count += substr_count($after, "\n");
  194. if( $current !== $root )
  195. throw new ParseError($this, 'missing {end}', $line_count + 1);
  196. // Add the last remaining content to the root node
  197. $after && $root->add('html')->set('content', $after);
  198. $this->root_block = $root;
  199. }
  200. /**
  201. * Replace blocks and variables in the template's content.
  202. *
  203. * @return string The template's content, with replaced blocks and variables.
  204. */
  205. function render() {
  206. // Use recursion to parse all blocks from the root level
  207. return self::render_block($this->root_block, $this);
  208. }
  209. /**
  210. * Render a single block, recursively parsing its sub-blocks with a given data scope.
  211. *
  212. * @param Node $block The block to render.
  213. * @param Node $data The data block to search in for the variable values.
  214. * @return string The rendered block.
  215. * @uses evaluate_expression()
  216. */
  217. private static function render_block(Node $block, Node $data) {
  218. $html = '';
  219. foreach( $block->get_children() as $child ) {
  220. switch( $child->get_name() ) {
  221. case 'html':
  222. $html .= $child->get('content');
  223. break;
  224. case 'block':
  225. $block_name = $child->get('name');
  226. foreach( $data->find($block_name) as $child_data )
  227. $html .= self::render_block($child, $child_data);
  228. break;
  229. case 'expression':
  230. $html .= self::evaluate_expression($child->get('content'), $data);
  231. }
  232. }
  233. return $html;
  234. }
  235. /**
  236. * Evaluate a <variable> expression.
  237. *
  238. * This function is a helper for {@link evaluate_expression()}.
  239. *
  240. * @param array $matches Regex matches for variable pattern.
  241. * @return string The evaluation of the variable.
  242. * @param Node $data A data tree containing variable values to use.
  243. * @throws \BadMethodCallException If an error occured while calling a variable method.
  244. * @throws \OutOfBoundsException If an unexisting array key is requested.
  245. * @throws \UnexpectedValueException In some other error situations.
  246. */
  247. private static function evaluate_variable(array $matches, Node $data) {
  248. $before = $matches[1];
  249. $variable = $matches[2];
  250. $value = $data->get($variable);
  251. if( count($matches) == 4 ) {
  252. // $<name>.<name>
  253. $attribute = $matches[3];
  254. if( $value === null ) {
  255. throw new \UnexpectedValueException(
  256. sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute)
  257. );
  258. }
  259. $attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) {
  260. throw new $class(
  261. sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error)
  262. );
  263. };
  264. if( is_array($value) ) {
  265. isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException');
  266. $value = $value[$attribute];
  267. } elseif( is_object($value) ) {
  268. isset($value->$attribute) || $attr_error('no such attribute');
  269. $value = $value->$attribute;
  270. } else {
  271. $attr_error('variable is no array or object');
  272. }
  273. } elseif( count($matches) == 5 ) {
  274. // $<name>.<name>()
  275. $method = $matches[3];
  276. if( $value === null ) {
  277. throw new \UnexpectedValueException(
  278. sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method)
  279. );
  280. }
  281. $method_error = function($error) use ($method, $variable) {
  282. throw new \BadMethodCallException(
  283. sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error)
  284. );
  285. };
  286. if( is_object($value) ) {
  287. method_exists($value, $method) || $method_error('no such method');
  288. $value = $value->$method();
  289. } else {
  290. $method_error('variable is no object');
  291. }
  292. }
  293. return $before . $value;
  294. }
  295. /**
  296. * Evaluate a conditional expression.
  297. *
  298. * This function is a helper for {@link evaluate_expression()}.
  299. *
  300. * @param array $matches Regex matches for conditional pattern.
  301. * @param Node $data A data tree containing variable values to use for
  302. * variable expressions.
  303. * @return string The evaluation of the condition.
  304. */
  305. private static function evaluate_condition(array $matches, Node $data) {
  306. if( self::evaluate_expression($matches[1], $data, false) ) {
  307. // Condition evaluates to true: return 'if' evaluation
  308. return self::evaluate_expression($matches[2], $data, false);
  309. } elseif( count($matches) == 4 ) {
  310. // <nested_exp>?<nested_exp>:<nested_exp>
  311. return self::evaluate_expression($matches[3], $data, false);
  312. }
  313. // No 'else' specified: evaluation is an empty string
  314. return '';
  315. }
  316. /**
  317. * Evaluate a static function call expression.
  318. *
  319. * This function is a helper for {@link evaluate_expression()}.
  320. *
  321. * @param array $matches Regex matches for function pattern.
  322. * @param Node $data A data tree containing variable values to use for
  323. * variable expressions.
  324. * @return string The evaluation of the function call.
  325. * @throws \BadFunctionCallException If the function is undefined.
  326. */
  327. private static function evaluate_function(array $matches, Node $data) {
  328. $function = $matches[1];
  329. $parameter = $matches[2];
  330. if( !is_callable($function) ) {
  331. throw new \BadFunctionCallException(
  332. sprintf('Cannot call function "%s": function is not callable', $function)
  333. );
  334. }
  335. $parameter_value = self::evaluate_expression($parameter, $data, false);
  336. return call_user_func($function, $parameter_value);
  337. }
  338. /**
  339. * Evaluate a PHP-constant expression.
  340. *
  341. * This function is a helper for {@link evaluate_expression()}.
  342. *
  343. * @param string $constant The name of the PHP constant.
  344. * @param bool $root_level Whether the expression was enclosed in curly
  345. * brackets (FALSE for sub-expressions);
  346. * @return string The evaluation of the constant if it is defined, the
  347. * original constant name otherwise.
  348. */
  349. private static function evaluate_constant($constant, $root_level) {
  350. if( defined($constant) )
  351. return constant($constant);
  352. return $root_level ? '{' . $constant . '}' : $constant;
  353. }
  354. /**
  355. * Evaluate an expression.
  356. *
  357. * The curly brackets should already have been stripped before passing an
  358. * expression to this method.
  359. *
  360. * @param string $expression The expression to evaluate.
  361. * @param Node $data A data tree containing variable values to use for
  362. * variable expressions.
  363. * @param bool $root_level Whether the expression was enclosed in curly
  364. * brackets (FALSE for sub-expressions);
  365. * @return string The evaluation of the expression if present, the
  366. * original string enclosed in curly brackets otherwise.
  367. */
  368. private static function evaluate_expression($expression, Node $data, $root_level=true) {
  369. if( $expression ) {
  370. $name = '[a-zA-Z0-9-_]+';
  371. $function = "$name(?:::$name)?";
  372. if( preg_match("/^([^?]*?)\s*\?([^:]*)(?::(.*))?$/", $expression, $matches) ) {
  373. // <nested_exp>?<nested_exp> | <nested_exp>?<nested_exp>:<nested_exp>
  374. return self::evaluate_condition($matches, $data);
  375. } elseif( preg_match("/^(.*?)\\$($name)(?:\.($name)(\(\))?)?$/", $expression, $matches) ) {
  376. // $<name> | $<name>.<name> | $<name>.<name>()
  377. return self::evaluate_variable($matches, $data);
  378. } elseif( preg_match("/^($function)\((.+?)\)?$/", $expression, $matches) ) {
  379. // <function>(<nested_exp>)
  380. return self::evaluate_function($matches, $data);
  381. } elseif( preg_match("/^([A-Z0-9_]+)$/", $expression, $matches) ) {
  382. // <constant>
  383. return self::evaluate_constant($expression, $root_level);
  384. } elseif( ($split_at = strpos($expression, '||', 1)) !== false ) {
  385. // <nested_exp>||<nested_exp>
  386. try {
  387. return self::evaluate_expression(substr($expression, 0, $split_at), $data, false);
  388. } catch(\RuntimeException $e) {
  389. return self::evaluate_expression(substr($expression, $split_at + 2), $data, false);
  390. }
  391. }
  392. }
  393. // No expression: return original string
  394. return $root_level ? '{' . $expression . '}' : $expression;
  395. }
  396. /**
  397. * Remove all current include paths.
  398. */
  399. static function clear_include_path() {
  400. self::$include_path = array();
  401. }
  402. /**
  403. * Replace all include paths by a single new one.
  404. *
  405. * @param string $path The new path to set as root.
  406. * @uses clear_include_path()
  407. */
  408. static function set_root($path) {
  409. self::clear_include_path();
  410. self::add_root($path);
  411. }
  412. /**
  413. * Add a new include path.
  414. *
  415. * @param string $path The path to add.
  416. * @throws FileNotFoundError If the path does not exist.
  417. */
  418. static function add_root($path) {
  419. if( $path[strlen($path) - 1] != '/' )
  420. $path .= '/';
  421. if( !is_dir($path) )
  422. throw new FileNotFoundError($path, true);
  423. self::$include_path[] = $path;
  424. }
  425. }
  426. /**
  427. * Error, thrown when an error occurs during the parsing of a template file.
  428. *
  429. * @package WebBasics
  430. */
  431. class ParseError extends \RuntimeException {
  432. /**
  433. * Constructor.
  434. *
  435. * Sets an error message with the path to the template file and a line number.
  436. *
  437. * @param Template $tpl The template in which the error occurred.
  438. * @param string $message A message describing the error.
  439. * @param int $line The line number at which the error occurred.
  440. */
  441. function __construct(Template $tpl, $message, $line) {
  442. $this->message = sprintf('Parse error in file %s, line %d: %s',
  443. $tpl->get_path(), $line, $message);
  444. }
  445. }
  446. ?>