template.php 15 KB

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