template.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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 string[] $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. $noescape_sign = $matches[2];
  250. $variable = $matches[3];
  251. $value = $data->get($variable);
  252. if( count($matches) == 5 ) {
  253. // $<name>.<name>
  254. $attribute = $matches[4];
  255. if( $value === null ) {
  256. throw new \UnexpectedValueException(
  257. sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute)
  258. );
  259. }
  260. $attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) {
  261. throw new $class(
  262. sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error)
  263. );
  264. };
  265. if( is_array($value) ) {
  266. isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException');
  267. $value = $value[$attribute];
  268. } elseif( is_object($value) ) {
  269. isset($value->$attribute) || $attr_error('no such attribute');
  270. $value = $value->$attribute;
  271. } else {
  272. $attr_error('variable is no array or object');
  273. }
  274. } elseif( count($matches) == 6 ) {
  275. // $<name>.<name>()
  276. $method = $matches[4];
  277. if( $value === null ) {
  278. throw new \UnexpectedValueException(
  279. sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method)
  280. );
  281. }
  282. $method_error = function($error) use ($method, $variable) {
  283. throw new \BadMethodCallException(
  284. sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error)
  285. );
  286. };
  287. if( is_object($value) ) {
  288. method_exists($value, $method) || $method_error('no such method');
  289. $value = $value->$method();
  290. } else {
  291. $method_error('variable is no object');
  292. }
  293. }
  294. // Escape value
  295. if( is_string($value) && !$noescape_sign )
  296. $value = self::escape_variable_value($value);
  297. return $before . $value;
  298. }
  299. /**
  300. * Escape a vairable value for displaying in HTML.
  301. *
  302. * Uses {@link http://php.net/htmlentities} with ENT_QUOTES.
  303. *
  304. * @param string $value The variable value to escape.
  305. * @return string The escaped value.
  306. */
  307. private static function escape_variable_value($value) {
  308. return htmlentities($value, ENT_QUOTES);
  309. }
  310. /**
  311. * Evaluate a conditional expression.
  312. *
  313. * This function is a helper for {@link evaluate_expression()}.
  314. *
  315. * @param array $matches Regex matches for conditional pattern.
  316. * @param Node $data A data tree containing variable values to use for
  317. * variable expressions.
  318. * @return string The evaluation of the condition.
  319. */
  320. private static function evaluate_condition(array $matches, Node $data) {
  321. if( self::evaluate_expression($matches[1], $data, false) ) {
  322. // Condition evaluates to true: return 'if' evaluation
  323. return self::evaluate_expression($matches[2], $data, false);
  324. } elseif( count($matches) == 4 ) {
  325. // <nested_exp>?<nested_exp>:<nested_exp>
  326. return self::evaluate_expression($matches[3], $data, false);
  327. }
  328. // No 'else' specified: evaluation is an empty string
  329. return '';
  330. }
  331. /**
  332. * Evaluate a static function call expression.
  333. *
  334. * This function is a helper for {@link evaluate_expression()}.
  335. *
  336. * @param array $matches Regex matches for function pattern.
  337. * @param Node $data A data tree containing variable values to use for
  338. * variable expressions.
  339. * @return string The evaluation of the function call.
  340. * @throws \BadFunctionCallException If the function is undefined.
  341. */
  342. private static function evaluate_function(array $matches, Node $data) {
  343. $function = $matches[1];
  344. $parameter = $matches[2];
  345. if( !is_callable($function) ) {
  346. throw new \BadFunctionCallException(
  347. sprintf('Cannot call function "%s": function is not callable', $function)
  348. );
  349. }
  350. $parameter_value = self::evaluate_expression($parameter, $data, false);
  351. return call_user_func($function, $parameter_value);
  352. }
  353. /**
  354. * Evaluate a PHP-constant expression.
  355. *
  356. * This function is a helper for {@link evaluate_expression()}.
  357. *
  358. * @param string $constant The name of the PHP constant.
  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 constant if it is defined, the
  362. * original constant name otherwise.
  363. */
  364. private static function evaluate_constant($constant, $root_level) {
  365. if( defined($constant) )
  366. return constant($constant);
  367. return $root_level ? '{' . $constant . '}' : $constant;
  368. }
  369. /**
  370. * Evaluate an expression.
  371. *
  372. * The curly brackets should already have been stripped before passing an
  373. * expression to this method.
  374. *
  375. * @param string $expression The expression to evaluate.
  376. * @param Node $data A data tree containing variable values to use for
  377. * variable expressions.
  378. * @param bool $root_level Whether the expression was enclosed in curly
  379. * brackets (FALSE for sub-expressions);
  380. * @return string The evaluation of the expression if present, the
  381. * original string enclosed in curly brackets otherwise.
  382. */
  383. private static function evaluate_expression($expression, Node $data, $root_level=true) {
  384. if( $expression ) {
  385. $name = '[a-zA-Z0-9-_]+';
  386. $function = "$name(?:::$name)?";
  387. if( preg_match("/^([^?]*?)\s*\?([^:]*)(?::(.*))?$/", $expression, $matches) ) {
  388. // <nested_exp>?<nested_exp> | <nested_exp>?<nested_exp>:<nested_exp>
  389. return self::evaluate_condition($matches, $data);
  390. } elseif( preg_match("/^(.*?)\\$(\\$?)($name)(?:\.($name)(\(\))?)?$/", $expression, $matches) ) {
  391. // $<name> | $<name>.<name> | $<name>.<name>()
  392. // | $$<name> | $$<name>.<name> | $$<name>.<name>()
  393. return self::evaluate_variable($matches, $data);
  394. } elseif( preg_match("/^($function)\((.+?)\)?$/", $expression, $matches) ) {
  395. // <function>(<nested_exp>)
  396. return self::evaluate_function($matches, $data);
  397. } elseif( preg_match("/^([A-Z0-9_]+)$/", $expression, $matches) ) {
  398. // <constant>
  399. return self::evaluate_constant($expression, $root_level);
  400. } elseif( ($split_at = strpos($expression, '||', 1)) !== false ) {
  401. // <nested_exp>||<nested_exp>
  402. try {
  403. return self::evaluate_expression(substr($expression, 0, $split_at), $data, false);
  404. } catch(\RuntimeException $e) {
  405. return self::evaluate_expression(substr($expression, $split_at + 2), $data, false);
  406. }
  407. }
  408. }
  409. // No expression: return original string
  410. return $root_level ? '{' . $expression . '}' : $expression;
  411. }
  412. /**
  413. * Remove all current include paths.
  414. */
  415. static function clear_include_path() {
  416. self::$include_path = array();
  417. }
  418. /**
  419. * Replace all include paths by a single new one.
  420. *
  421. * @param string $path The new path to set as root.
  422. * @uses clear_include_path()
  423. */
  424. static function set_root($path) {
  425. self::clear_include_path();
  426. self::add_root($path);
  427. }
  428. /**
  429. * Add a new include path.
  430. *
  431. * @param string $path The path to add.
  432. * @throws FileNotFoundError If the path does not exist.
  433. */
  434. static function add_root($path) {
  435. if( $path[strlen($path) - 1] != '/' )
  436. $path .= '/';
  437. if( !is_dir($path) )
  438. throw new FileNotFoundError($path, true);
  439. self::$include_path[] = $path;
  440. }
  441. }
  442. /**
  443. * Error, thrown when an error occurs during the parsing of a template file.
  444. *
  445. * @package WebBasics
  446. */
  447. class ParseError extends \RuntimeException {
  448. /**
  449. * Constructor.
  450. *
  451. * Sets an error message with the path to the template file and a line number.
  452. *
  453. * @param Template $tpl The template in which the error occurred.
  454. * @param string $message A message describing the error.
  455. * @param int $line The line number at which the error occurred.
  456. */
  457. function __construct(Template $tpl, $message, $line) {
  458. $this->message = sprintf('Parse error in file %s, line %d: %s',
  459. $tpl->get_path(), $line, $message);
  460. }
  461. }
  462. ?>