Simplifying the loading of templates so we don't have two exceptions to throw for...
[isso.git] / Template.php
1 <?php
2 /*=====================================================================*\
3 || ###################################################################
4 || # Blue Static ISSO Framework
5 || # Copyright ©2002-[#]year[#] Blue Static
6 || #
7 || # This program is free software; you can redistribute it and/or modify
8 || # it under the terms of the GNU General Public License as published by
9 || # the Free Software Foundation; version [#]gpl[#] of the License.
10 || #
11 || # This program is distributed in the hope that it will be useful, but
12 || # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
13 || # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14 || # more details.
15 || #
16 || # You should have received a copy of the GNU General Public License along
17 || # with this program; if not, write to the Free Software Foundation, Inc.,
18 || # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
19 || ###################################################################
20 \*=====================================================================*/
21
22 /**
23 * Database-Driven Template System (template.php)
24 *
25 * @package ISSO
26 */
27
28 require_once('ISSO/Functions.php');
29
30 /**
31 * File-Based Template System
32 *
33 * This framework merely replaces the template loading functions with
34 * file-system based ones. It has an optional caching system in which
35 * template data will remain stored in the database as long as the filesystem
36 * file is modified. To do this, pass a table name to setDatabaseCache() and make sure
37 * there's a DB module that has access to a table with this schema:
38 *
39 * CREATE TABLE template (filename VARCHAR (250) NOT NULL, template TEXT NOT NULL, timestamp INT NOT NULL);
40 *
41 * @author Blue Static
42 * @copyright Copyright ©2002 - [#]year[#], Blue Static
43 * @version $Revision$
44 * @package ISSO
45 *
46 */
47 class BSTemplate
48 {
49 /**
50 * The path, from the path of the application, where templates are stored
51 * @var string
52 */
53 private $templateDir = '';
54
55 /**
56 * The extension all the template files have
57 * @var string
58 */
59 private $extension = 'tpl';
60
61 /**
62 * The database table name for the template cache
63 * @var string
64 */
65 private $dbCacheTable = null;
66
67 /**
68 * The name of the function phrases are fetched with
69 * @var string
70 */
71 private $langcall = 'gettext';
72
73 /**
74 * The name of the function phrases are sprintf() parsed with
75 * @var string
76 */
77 private $langconst = 'sprintf';
78
79 /**
80 * Array of pre-compiled templates that are stored to decrease server load
81 * @var array
82 */
83 protected $cache = array();
84
85 /**
86 * A list of the number of times each template has been used
87 * @var array
88 */
89 protected $usage = array();
90
91 /**
92 * A list of templates that weren't cached, but are still used
93 * @var array
94 */
95 protected $uncached = array();
96
97 /**
98 * Whether or not the page has been flush()'d already
99 * @var bool
100 */
101 private $doneflush = false;
102
103 /**
104 * The name of a function that is called before template parsing of phrases and conditionals occurs
105 * @var string
106 */
107 private $preParseHook = ':undefined:';
108
109 // ###################################################################
110 /**
111 * Sets the template directory name
112 *
113 * @param string Template directory name
114 */
115 public function setTemplateDirectory($dir)
116 {
117 $this->templateDir = BSFunctions::FetchSourcePath($dir);
118 }
119
120 // ###################################################################
121 /**
122 * Sets the file extension for the templates
123 *
124 * @param string File extension
125 */
126 public function setExtension($ext)
127 {
128 $this->extension = $ext;
129 }
130
131 // ###################################################################
132 /**
133 * Sets the name of the table to access for the datbase cache
134 *
135 * @param string DB table name
136 */
137 public function setDatabaseCache($table)
138 {
139 $this->dbCacheTable = $table;
140 }
141
142 // ###################################################################
143 /**
144 * Sets the pre-parse hook method which is called before any other
145 * processing is done on the template.
146 *
147 * @param string Method name
148 */
149 public function setPreParseHook($hook)
150 {
151 $this->preParseHook = $hook;
152 }
153
154 // ###################################################################
155 /**
156 * Takes an array of template names, loads them, and then stores a
157 * parsed version for optimum speed.
158 *
159 * @param array List of template names to be cached
160 */
161 public function cache($namearray)
162 {
163 if (sizeof($this->cache) > 0)
164 {
165 throw new Exception('You cannot cache templates more than once per initialization');
166 }
167 else
168 {
169 $dbCache = array();
170 if ($this->dbCacheTable)
171 {
172 $db =& BSApp::GetType('Db');
173 $cache = $db->query("SELECT * FROM {$this->dbCacheTable} WHERE filename IN ('" . implode("', '", $namearray) . "')");
174 while ($tpl = $db->fetchArray($cache))
175 {
176 $time = filemtime($this->templateDir . $tpl['filename'] . '.' . $this->extension);
177 $template = $tpl['template'];
178 if ($time > $tpl['timestamp'])
179 {
180 $template = $this->_parseTemplate($this->_loadTemplate($tpl['filename']));
181 $db->query("UPDATE {$this->dbCacheTable} SET template = '" . $db->escapeString($template) . "', timestamp = " . TIMENOW . " WHERE filename = '" . $tpl['filename'] . "'");
182 $tpl['template'] = $template;
183 }
184 $dbCache["$tpl[filename]"] = $template;
185 }
186 }
187 foreach ($namearray AS $name)
188 {
189 if ($this->dbCacheTable)
190 {
191 if (isset($dbCache["$name"]))
192 {
193 $template = $dbCache["$name"];
194 }
195 else
196 {
197 $template = $this->_parseTemplate($this->_loadTemplate($name));
198 $db->query("INSERT INTO {$this->dbCacheTable} (filename, template, timestamp) VALUES ('$name', '" . $db->escapeString($template) . "', " . TIMENOW . ")");
199 }
200 }
201 else
202 {
203 $template = $this->_parseTemplate($this->_loadTemplate($name));
204 }
205
206 $this->cache["$name"] = $template;
207 $this->usage["$name"] = 0;
208 }
209 }
210 }
211
212 // ###################################################################
213 /**
214 * Loads a template from the cache or the _load function and stores the
215 * parsed version of it
216 *
217 * @param string The name of the template
218 *
219 * @return string A parsed and loaded template
220 */
221 public function fetch($name)
222 {
223 if (isset($this->cache["$name"]))
224 {
225 $template = $this->cache["$name"];
226 }
227 else
228 {
229 $this->uncached[] = $name;
230 BSApp::Debug("Manually loading template '$name'");
231 $template = $this->_loadTemplate($name);
232 $template = $this->_parseTemplate($template);
233 }
234
235 if (!isset($this->usage["$name"]))
236 {
237 $this->usage["$name"] = 0;
238 }
239
240 $this->usage["$name"]++;
241
242 return $template;
243 }
244
245 // ###################################################################
246 /**
247 * Output a template fully compiled to the browser
248 *
249 * @param string Compiled and ready template
250 */
251 public function flush($template)
252 {
253 ob_start();
254
255 if (empty($template))
256 {
257 throw new Exception('There was no output to print');
258 }
259
260 if ($this->doneflush)
261 {
262 throw new Exception('A template has already been sent to the output buffer');
263 }
264
265 $debugBlock = '';
266 if (BSApp::GetDebug())
267 {
268 if (defined('SVN') AND preg_match('#^\$Id:?#', constant('SVN')))
269 {
270 $debugBlock .= preg_replace('#\$' . 'Id: (.+?) ([0-9].+?) [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(.+?) (.+?) \$#', "\n<br />\n" . '<div align="center"><strong>\1</strong> &mdash; r\2</div>', constant('SVN'));
271 }
272
273 if (defined('ISSO_MT_START'))
274 {
275 $debugBlock .= "\n<div align=\"center\">Executed in " . round(BSFunctions::FetchMicrotimeDiff(ISSO_MT_START), 10) . ' seconds</div>';
276 }
277
278 $debugBlock .= "\n<br /><div align=\"center\">" . BSApp::GetDebugList() . "</div>";
279
280 $optlist = array();
281 $usage = array();
282 foreach ($this->usage AS $name => $count)
283 {
284 if (in_array($name, $this->uncached))
285 {
286 $optlist[] = $name . '[' . $count . ']';
287 }
288 $usage[] = $name . " ($count)";
289 }
290 $sizeof = sizeof($this->uncached);
291 if ($sizeof > 0)
292 {
293 $debugBlock .= "<br /><div style=\"color: red\" align=\"center\"><strong>Uncached Template(s):</strong> $sizeof ( " . implode(' &nbsp; ', $optlist) . " )</div>\n";
294 }
295
296 $debugBlock .= (sizeof($this->uncached) < 1 ? "<br />\n" : '') . "<div align=\"center\"><select><option>Template Usage (" . array_sum($this->usage) . ")</option>";
297 foreach ($usage AS $tpl)
298 {
299 $debugBlock .= "<option>--- $tpl</option>";
300 }
301 $debugBlock .= "</select></div>\n";
302
303 if (BSApp::GetType('Db'))
304 {
305 $queries = BSApp::GetType('Db')->getHistory();
306
307 $debugBlock .= "<br />\n" . '<table cellpadding="4" cellspacing="1" border="0" align="center" width="30%" style="background-color: rgb(60, 60, 60); color: white">' . "\n\t" . '<tr><td><strong>Query Debug</strong></td></tr>';
308
309 foreach ($queries AS $query)
310 {
311 $debugBlock .= "\n\t<tr style=\"background-color: rgb(230, 230, 230); color: black\">";
312 $debugBlock .= "\n\t\t<td>";
313 $debugBlock .= "\n\t\t\t$query[query]\n\n\t\t\t<div style=\"font-size: 9px;\">($query[time])</div>\n<!--\n$query[trace]\n-->\n\t\t</td>\n\t</tr>";
314 }
315
316 $debugBlock .= "\n</table>\n\n\n";
317 }
318 }
319
320 $template = str_replace('</body>', $debugBlock . '</body>', $template);
321
322 print($template);
323 }
324
325 // ###################################################################
326 /**
327 * Loads an additional template from the database
328 *
329 * @param string The name of the template
330 *
331 * @return string Template data from the database
332 */
333 protected function _loadTemplate($name)
334 {
335 $path = $this->templateDir . $name . '.' . $this->extension;
336 if (is_file($path) AND is_readable($path))
337 {
338 return @file_get_contents($path);
339 }
340 else
341 {
342 throw new Exception("Could not load the template '$path'");
343 }
344 }
345
346 // ###################################################################
347 /**
348 * A wrapper for all the parsing functions and compiling functins
349 *
350 * @param string Unparsed template data
351 *
352 * @return string Parsed template data
353 */
354 protected function _parseTemplate($template)
355 {
356 $template = str_replace('"', '\"', $template);
357
358 if (function_exists($this->preParseHook))
359 {
360 $template = call_user_func($this->preParseHook, $template);
361 }
362
363 $template = $this->_parseBlocksAndTokens($template);
364 $template = $this->_parsePhrases($template);
365 $template = $this->_parseConditionals($template);
366 return $template;
367 }
368
369 // ###################################################################
370 /**
371 * Parses anything with curly braces {} (including phrases)
372 *
373 * @param string Template data
374 *
375 * @return string Parsed template data
376 */
377 private function _parseBlocksAndTokens($template)
378 {
379 $stack = array();
380 $tokens = array();
381
382 while (1)
383 {
384 for ($i = 0; $i < strlen($template); $i++)
385 {
386 // we've run through the template and there's nothing in the stack--done
387 if ($i == strlen($template) - 1 AND sizeof($stack) == 0)
388 {
389 return $template;
390 }
391
392 if ($template[$i] == '{')
393 {
394 // ignore escaped sequences
395 if ($template[$i - 1] != '\\')
396 {
397 array_push($stack, $i);
398 }
399 }
400 else if ($template[$i] == '}')
401 {
402 // there's no stack so it was probably escaped
403 if (sizeof($stack) == 0)
404 {
405 continue;
406 }
407 // we're good and nested
408 else if (sizeof($stack) == 1)
409 {
410 $open = array_pop($stack);
411 $token = substr($template, $open, $i - $open + 1);
412 $template = str_replace($token, $this->_parseToken($token), $template);
413 break;
414 }
415 // just pop it off
416 else
417 {
418 array_pop($stack);
419 }
420 }
421 }
422 }
423 }
424
425 // ###################################################################
426 /**
427 * Parses a curly brace token {}
428 *
429 * @param string Token
430 *
431 * @return string Parsed value
432 */
433 private function _parseToken($token)
434 {
435 // knock of the braces
436 $token = substr($token, 1, strlen($token) - 2);
437
438 // language token
439 if ($token[0] == '@' AND $token[1] == '\\' AND $token[2] == '"')
440 {
441 return '" . ' . $this->langcall . '(\'' . str_replace(array('\\\"', "'"), array('"', "\'"), substr($token, 3, strlen($token) - 5)) . '\')' . ' . "';
442 }
443 // normal PHP code
444 else
445 {
446 return '" . (' . $token . ') . "';
447 }
448 }
449
450 // ###################################################################
451 /**
452 * Prepares language and locale information inside templates
453 *
454 * @param string Template data to be processed
455 *
456 * @return string Language-ready template data
457 */
458 private function _parsePhrases($template)
459 {
460 $tagStart = '<lang ';
461 $tagEnd = '</lang>';
462
463 $start = -1; // start of open tag
464 $end = -1; // start of the close tag
465 $varEnd = -1; // end of the open tag
466
467 while ($start <= strlen($template))
468 {
469 // reset
470 $varMap = array(); // storage for all the substitution indices
471
472 // Find the start language object tag
473 $start = strpos($template, $tagStart, $end + 1);
474 if ($start === false)
475 {
476 break;
477 }
478
479 // look ahead to parse out all the variables
480 $i = $start + strlen($tagStart); // current position
481 $capture = ''; // current capture
482 $capturePos = $i; // the place to start capturing
483 $varNum = -1; // variable placeholder index
484 while ($i < strlen($template))
485 {
486 if ($template[$i] == '=')
487 {
488 // backtrack to find the previous variable substitution
489 $backPos = $i;
490 while ($backPos >= $start)
491 {
492 if ($template[$backPos] == '"')
493 {
494 // startPosition + length(startTag) + length(=\")
495 $varMap[intval($varNum)] = BSFunctions::Substring($template, $capturePos + 3, $backPos - 1);
496 // remove our old substitution from the capture
497 $capture = BSFunctions::Substring($template, $backPos + 1, $i);
498 break;
499 }
500 $backPos--;
501 }
502
503 // do we have a valid index?
504 if (intval($capture) > 0)
505 {
506 // set aside the index and restart capturing
507 $varNum = $capture;
508 $capture = '';
509 $capturePos = $i;
510 }
511 else
512 {
513 throw new Exception('Invalid language variable index "' . $capture . '"');
514 }
515 }
516 else if ($template[$i] == '>' AND $template[$i - 1] == '"')
517 {
518 // the final variable substitution
519 $varMap[intval($varNum)] = BSFunctions::Substring($template, $capturePos + 3, $i - 2);
520 $varEnds = $i;
521 break;
522 }
523
524 $capture .= $template[$i];
525 $i++;
526 }
527
528 // locate the end tag
529 $end = strpos($template, $tagEnd, $i);
530 if ($end === false)
531 {
532 break;
533 }
534
535 // this is the string that gets variable replacement
536 $str = BSFunctions::Substring($template, $varEnds + 1, $end);
537
538 // create the complete varmap
539
540 for ($i = max(array_keys($varMap)); $i > 0; $i--)
541 {
542 if (!isset($varMap[$i]))
543 {
544 $varMap[$i] = '<strong>[MISSING SUBSTITUTION INDEX: ' . $i . ']</strong>';
545 }
546 }
547
548 // put all the keys in corresponding argument order
549 ksort($varMap);
550
551 // FINALLY, construct the call to sprintf()
552 $template = substr_replace($template, '" . ' . $this->langconst . '(\'' . $str . '\', "' . implode('", "', $varMap) . '") . "', $start, ($end + strlen($tagEnd)) - $start);
553 }
554
555 return $template;
556 }
557
558 // ###################################################################
559 /**
560 * Parser for in-line template conditionals
561 *
562 * @param string Template data awaiting processing
563 *
564 * @return string Parsed template data
565 */
566 private function _parseConditionals($template)
567 {
568 // tag data
569 $tag_start = '<if condition=\"';
570 $tag_start_end = '\">';
571 $tag_else = '<else />';
572 $tag_end = '</if>';
573
574 // tag stack
575 $stack = array();
576
577 // the information about the current active tag
578 $tag_full = array();
579 $parsed = array();
580
581 // start at 0
582 $offset = 0;
583
584 while (1)
585 {
586 if (strpos($template, $tag_start) === false)
587 {
588 break;
589 }
590
591 for ($i = $offset; $i < strlen($template); $i++)
592 {
593 // we've found ourselves a conditional!
594 if (substr($template, $i, strlen($tag_start)) == $tag_start)
595 {
596 // push the position into the tag stack
597 if ($tag_full)
598 {
599 array_push($stack, $i);
600 }
601 else
602 {
603 $tag_full['posi'] = $i;
604 }
605 }
606 // locate else tags
607 else if (substr($template, $i, strlen($tag_else)) == $tag_else)
608 {
609 if (sizeof($stack) == 0 AND !isset($tag_full['else']))
610 {
611 $tag_full['else'] = $i;
612 }
613 }
614 // do we have an end tag?
615 else if (substr($template, $i, strlen($tag_end)) == $tag_end)
616 {
617 if (sizeof($stack) != 0)
618 {
619 array_pop($stack);
620 continue;
621 }
622
623 // calculate the position of the end tag
624 $tag_full['posf'] = $i + strlen($tag_end) - 1;
625
626 // extract the entire conditional from the template
627 $fullspread = substr($template, $tag_full['posi'], $tag_full['posf'] - $tag_full['posi'] + 1);
628
629 // remove the beginning tag
630 $conditional = substr($fullspread, strlen($tag_start));
631
632 // find the end of the expression
633 $temp_end = strpos($conditional, $tag_start_end);
634
635 // save the expression
636 $parsed[0] = stripslashes(substr($conditional, 0, $temp_end));
637
638 // remove the expression from the conditional
639 $conditional = substr($conditional, strlen($parsed[0]) + strlen($tag_start_end));
640
641 // remove the tailing end tag
642 $conditional = substr($conditional, 0, strlen($conditional) - strlen($tag_end));
643
644 // handle the else
645 if (isset($tag_full['else']))
646 {
647 // now relative to the start of the <if>
648 $relpos = $tag_full['else'] - $tag_full['posi'];
649
650 // calculate the length of the expression and opening tag
651 $length = strlen($parsed[0]) + strlen($tag_start) + strlen($tag_start_end);
652
653 // relative to the start of iftrue
654 $elsepos = $relpos - $length;
655
656 $parsed[1] = substr($conditional, 0, $elsepos);
657 $parsed[2] = substr($conditional, $elsepos + strlen($tag_else));
658 }
659 // no else to handle
660 else
661 {
662 $parsed[1] = $conditional;
663 $parsed[2] = '';
664 }
665
666 // final parsed output
667 $parsed = '" . ((' . stripslashes($parsed[0]) . ') ? "' . $parsed[1] . '" : "' . $parsed[2] . '") . "';
668
669 // replace the conditional
670 $template = str_replace($fullspread, $parsed, $template);
671
672 // reset the parser
673 $offset = $tag_full['posi'] + strlen($tag_start) + strlen($tag_start_end);
674 $tag_full = array();
675 $stack = array();
676 $parsed = array();
677 unset($fullspread, $conditional, $temp_end, $relpos, $length, $elsepos);
678 break;
679 }
680 }
681 }
682
683 return $template;
684 }
685 }
686
687 // ###################################################################
688 /**
689 * Debugging function used to print characters in a string that are
690 * around a certain position.
691 *
692 * @access private
693 *
694 * @param string The haystack string
695 * @param integer Position to print around
696 */
697 function print_around($str, $pos)
698 {
699 echo '>>> PA >>>>>>>>[';
700 echo htmlspecialchars($str[ $pos - 5 ]);
701 echo htmlspecialchars($str[ $pos - 4 ]);
702 echo htmlspecialchars($str[ $pos - 3 ]);
703 echo htmlspecialchars($str[ $pos - 2 ]);
704 echo htmlspecialchars($str[ $pos - 1 ]);
705 echo '©';
706 echo htmlspecialchars($str[ $pos + 0 ]);
707 echo htmlspecialchars($str[ $pos + 1 ]);
708 echo htmlspecialchars($str[ $pos + 2 ]);
709 echo htmlspecialchars($str[ $pos + 3 ]);
710 echo htmlspecialchars($str[ $pos + 4 ]);
711 echo htmlspecialchars($str[ $pos + 5 ]);
712 echo ']<<<<<<<< PA <<<';
713 }
714
715 /*=====================================================================*\
716 || ###################################################################
717 || # $HeadURL$
718 || # $Id$
719 || ###################################################################
720 \*=====================================================================*/
721 ?>