Refactor builtin functions into a TemplateBuiltins class.
[hoplite.git] / views / template.php
1 <?php
2 // Hoplite
3 // Copyright (c) 2011 Blue Static
4 //
5 // This program is free software: you can redistribute it and/or modify it
6 // under the terms of the GNU General Public License as published by the Free
7 // Software Foundation, either version 3 of the License, or any later version.
8 //
9 // This program is distributed in the hope that it will be useful, but WITHOUT
10 // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12 // more details.
13 //
14 // You should have received a copy of the GNU General Public License along with
15 // this program. If not, see <http://www.gnu.org/licenses/>.
16
17 namespace hoplite\views;
18
19 use \hoplite\base\Profiling;
20
21 require_once HOPLITE_ROOT . '/base/filter.php';
22 require_once HOPLITE_ROOT . '/base/profiling.php';
23
24 /*!
25 Template parses a a text file (typically HTML) by expanding a small macro
26 language into "compiled" PHP.
27
28 The opening and close tags are user-customizable but the default is {% %}.
29
30 The open and close tags are translated to '<?php' and '?>', respectively.
31
32 Modifiers may be placed after the open and close tags as shorthand for
33 further shorthand.
34
35 {% expression %}
36 Evaluates a non-printing expression, treating it as pure PHP. This can
37 be used to evaluate conditions:
38 {% if (!$user->user_id): %}<p>Hello, Guest!</p>{% endif %}
39
40 {%= $value %}
41 Prints the value and automatically HTML-escapes it.
42
43 {%= $value | int }
44 Prints $value by coerceing it to an integer. Other coercion types
45 are str (the default if no pipe symbol, above), int, float, and
46 raw (no escaping).
47
48 {%# macro_function arg1, arg2, ... argN %}
49 Runs the built-in macro_function with the specified arguments.
50 The built-ins are:
51 * import(template_name:string, new_variables:array)
52 Imports a subtemplate into the current scope, giving it access
53 to all the current variables, while overriding and defining
54 new ones via new_variables.
55 * url(path:string)
56 Constructs an absolute URL to the given path from the script
57 root.
58 */
59 class Template
60 {
61 /*! @var string The macro opening delimiter. */
62 static protected $open_tag = '{%';
63
64 /*! @var string The macro closing delimiter. */
65 static protected $close_tag = '%}';
66
67 /*! @var string The name of the template. */
68 protected $template_name = '';
69
70 /*! @var string The compiled template. */
71 protected $data = NULL;
72
73 /*! @var array Variables to provide to the template. */
74 protected $vars = array();
75
76 /*!
77 Creates a new Template with a given name.
78 @param string
79 */
80 private function __construct($name)
81 {
82 $this->template_name = $name;
83 }
84
85 static public function NewWithData($name, $data)
86 {
87 $template = new Template($name);
88 $template->data = $template->_ProcessTemplate($data);
89 return $template;
90 }
91
92 static public function NewWithCompiledData($name, $data)
93 {
94 $template = new Template($name);
95 $template->data = $data;
96 return $template;
97 }
98
99 /*!
100 Sets the open and closing delimiters. The caller is responsible for choosing
101 values that will not cause the parser to barf.
102 */
103 static public function set_open_close_tags($open_tag, $close_tag)
104 {
105 self::$open_tag = $open_tag;
106 self::$close_tag = $close_tag;
107 }
108
109 /*! Gets the name of the template. */
110 public function template_name() { return $this->template_name; }
111
112 /*! Gets the parsed data of the template. */
113 public function template() { return $this->data; }
114
115 /*! Overload property accessors to set view variables. */
116 public function __get($key)
117 {
118 return $this->vars[$key];
119 }
120 public function __set($key, $value)
121 {
122 $this->vars[$key] = $value;
123 }
124
125 /*! This includes the template and renders it out. */
126 public function Render($vars = array())
127 {
128 $__template_data = $this->data;
129 $__template_vars = array_merge($this->vars, $vars);
130 $render = function () use ($__template_data, $__template_vars) {
131 extract($__template_vars);
132 eval('?>' . $__template_data . '<' . '?');
133 };
134
135 ob_start();
136
137 $error = error_reporting();
138 error_reporting($error & ~E_NOTICE);
139
140 $render();
141
142 error_reporting($error);
143
144 if (Profiling::IsProfilingEnabled())
145 TemplateLoader::GetInstance()->MarkTemplateRendered($this->name);
146
147 $data = ob_get_contents();
148 ob_end_clean();
149 return $data;
150 }
151
152 /*! @brief Does any pre-processing on the template.
153 This performs the macro expansion. The language is very simple and is merely
154 shorthand for the PHP tags.
155
156 @param string Raw template data
157 @return string Executable PHP
158 */
159 protected function _ProcessTemplate($data)
160 {
161 // The parsed output as compiled PHP.
162 $processed = '';
163
164 // If processing a macro, this contains the contents of the macro while
165 // it is being extracted from the template.
166 $macro = '';
167 $length = strlen($data);
168 $i = 0; // The current position of the iterator.
169 $looking_for_end = FALSE; // Whehter or not an end tag is expected.
170 $line_number = 1; // The current line number.
171 $i_last_line = 0; // The value of |i| at the previous new line, used for column numbering.
172
173 $open_tag_len = strlen(self::$open_tag);
174 $close_tag_len = strlen(self::$close_tag);
175
176 while ($i < $length) {
177 // See how far the current position is from the end of the string.
178 $delta = $length - $i;
179
180 // When a new line is reached, update the counters.
181 if ($data[$i] == "\n") {
182 ++$line_number;
183 $i_last_line = $i;
184 }
185
186 // Check for the open tag.
187 if ($delta >= $open_tag_len &&
188 substr($data, $i, $open_tag_len) == self::$open_tag) {
189 // If an expansion has already been opened, then it's an error to nest.
190 if ($looking_for_end) {
191 $column = $i - $i_last_line;
192 throw new TemplateException("Unexpected start of expansion at line $line_number:$column");
193 }
194
195 $looking_for_end = TRUE;
196 $macro = '';
197 $i += $open_tag_len;
198 continue;
199 }
200 // Check for the close tag.
201 else if ($delta >= $close_tag_len &&
202 substr($data, $i, $close_tag_len) == self::$close_tag) {
203 // If an end tag was encountered without an open tag, that's an error.
204 if (!$looking_for_end) {
205 $column = $i - $i_last_line;
206 throw new TemplateException("Unexpected end of expansion at line $line_number:$column");
207 }
208
209 $expanded_macro = $this->_ProcessMacro($macro);
210 $processed .= "<?php $expanded_macro ?>";
211 $looking_for_end = FALSE;
212 $i += $close_tag_len;
213 continue;
214 }
215
216 // All other characters go into a storage bin. If currently in a macro,
217 // save off the data separately for parsing.
218 if ($looking_for_end)
219 $macro .= $data[$i];
220 else
221 $processed .= $data[$i];
222 ++$i;
223 }
224
225 return $processed;
226 }
227
228 /*!
229 Takes the contents of a macro, i.e. the string between the open and close
230 tags, and transforms it into a PHP statement.
231 */
232 protected function _ProcessMacro($macro)
233 {
234 if (strlen($macro) < 1)
235 return $macro;
236
237 // If the macro has a modifier character, as described in the class comment,
238 // futher thransform the statement.
239 switch ($macro[0]) {
240 case '=': return $this->_ProcessInterpolation(substr($macro, 1));
241 case '#': return $this->_ProcessBuiltin(substr($macro, 1));
242 default: return $macro;
243 }
244 }
245
246 /*!
247 Creates a printing expression for a value, optionally coercing and escaping
248 it to a specific type.
249 */
250 protected function _ProcessInterpolation($macro)
251 {
252 // The pipe operator specifies how to sanitize the output.
253 $formatter_pos = strrpos($macro, '|');
254
255 // No specifier defaults to escaped string.
256 if ($formatter_pos === FALSE)
257 return 'echo hoplite\\base\\filter\\String(' . $macro . ')';
258
259 // Otherwise, apply the right filter.
260 $formatter = trim(substr($macro, $formatter_pos + 1));
261 $function = '';
262 switch (strtolower($formatter)) {
263 case 'int': $function = 'Int'; break;
264 case 'float': $function = 'Float'; break;
265 case 'str': $function = 'String'; break;
266 case 'raw': $function = 'RawString'; break;
267 default:
268 throw new TemplateException('Invalid macro formatter "' . $formatter . '"');
269 }
270
271 // Now get the expression and return a PHP statement.
272 $expression = trim(substr($macro, 0, $formatter_pos));
273 return 'echo hoplite\\base\\filter\\' . $function . '(' . $expression . ')';
274 }
275
276 protected function _ProcessBuiltin($macro)
277 {
278 $macro = trim($macro);
279 $function_pos = strpos($macro, ' ');
280 if ($function_pos === FALSE)
281 throw new TemplateException('No macro function specified');
282
283 $function = substr($macro, 0, $function_pos);
284 $args = substr($macro, $function_pos + 1);
285 switch ($function) {
286 case 'url': return $this->_Builtin_url($args);
287 case 'import': return $this->_Builtin_import($args);
288 default:
289 throw new TemplateException("Invalid macro function '$function'");
290 }
291 }
292
293 protected function _Builtin_url($args)
294 {
295 return "hoplite\\views\\TemplateBuiltins::MakeURL($args)";
296 }
297
298 protected function _Builtin_import($args)
299 {
300 $template = $args;
301 $vars = '';
302
303 $template_pos = strpos($args, ',');
304 if ($template_pos !== FALSE) {
305 $template = substr($args, 0, $template_pos);
306 $vars = substr($args, $template_pos + 1);
307 }
308
309 if ($vars)
310 $vars = "array_merge(\$__template_vars, $vars)";
311 else
312 $vars = '$__template_vars';
313
314 return "hoplite\\views\\TemplateBuiltins::Import($template, $vars)";
315 }
316 }
317
318 /*!
319 Namespace for builtin template macros.
320 */
321 class TemplateBuiltins
322 {
323 /*!
324 Renders a template with additional vars.
325 @param string The template name to render
326 @param array Variables with which to render.
327 */
328 static public function Import($template, $vars)
329 {
330 echo TemplateLoader::Fetch($template)->Render($vars);
331 }
332
333 /*! @brief Creates a URL via RootController::MakeURL().
334 This requires the root controller be set in the $GLOBALS as
335 hoplite\http\RootController.
336 @param string Path.
337 */
338 static public function MakeURL($path)
339 {
340 echo $GLOBALS['hoplite\http\RootController']->MakeURL($path, FALSE);
341 }
342 }
343
344 class TemplateException extends \Exception {}