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