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