From 4cf85a06380887f89b48cdef62541d03f6317f6c Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Tue, 26 Jul 2011 08:54:38 -0400 Subject: [PATCH] Add basic template parser and test --- base/filter.php | 57 +++++++ testing/tests/views/template_test.php | 68 ++++++++ views/template.php | 219 ++++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 base/filter.php create mode 100644 testing/tests/views/template_test.php create mode 100644 views/template.php diff --git a/base/filter.php b/base/filter.php new file mode 100644 index 0000000..61a2bfd --- /dev/null +++ b/base/filter.php @@ -0,0 +1,57 @@ +. + +namespace hoplite\base\filter; + +function TrimmedString($str) +{ + return trim($str); +} + +function String($str) +{ + $find = array( + '<', + '>', + '"' + ); + $replace = array( + '<', + '>', + '"' + ); + return str_replace($find, $replace, $str); +} + +function Int($int) +{ + return intval($int); +} + +function Float($float) +{ + return floatval($float); +} + +function Bool($bool) +{ + $str = strtolower(TrimmedString($bool)); + if ($str == 'yes' || $str == 'true') + return TRUE; + else if ($str == 'no' || $str == 'false') + return FALSE; + return (bool)$bool; +} diff --git a/testing/tests/views/template_test.php b/testing/tests/views/template_test.php new file mode 100644 index 0000000..0a64e18 --- /dev/null +++ b/testing/tests/views/template_test.php @@ -0,0 +1,68 @@ +. + +namespace hoplite\test; +use hoplite\views\Template; + +require_once HOPLITE_ROOT . '/views/template.php'; + +class TemplateTest extends \PHPUnit_Framework_TestCase +{ + private function _Render($template) + { + ob_start(); + $template->Render(); + $data = ob_get_contents(); + ob_end_clean(); + return $data; + } + + public function testRenderSimple() + { + $template = Template::NewWithData('Hello World'); + $this->assertEquals('Hello World', $this->_Render($template)); + } + + public function testRender1Var() + { + $template = Template::NewWithData('Hello, {% $name | str %}'); + $template->name = 'Robert'; + $this->assertEquals('Hello, Robert', $this->_Render($template)); + } + + public function testRender2Vars() + { + $template = Template::NewWithData('Hello, {% $name %}. Today is the {% $date->day %} of July.'); + $date = new \stdClass(); + $date->day = 26; + $template->name = 'Robert'; + $template->date = $date; + $this->assertEquals('Hello, Robert. Today is the 26 of July.', $this->_Render($template)); + } + + public function testRenderIf() + { + $template = Template::NewWithData( + 'You are {!% if (!$user->logged_in): %}not logged in{!% else: %}{% $user->name %}{!% endif %}'); + $template->user = new \stdClass(); + $template->user->logged_in = TRUE; + $template->user->name = 'Robert'; + $this->assertEquals('You are Robert', $this->_Render($template)); + + $template->user->logged_in = FALSE; + $this->assertEquals('You are not logged in', $this->_Render($template)); + } +} diff --git a/views/template.php b/views/template.php new file mode 100644 index 0000000..f9cb5e6 --- /dev/null +++ b/views/template.php @@ -0,0 +1,219 @@ +. + +namespace hoplite\views; + +require_once HOPLITE_ROOT . '/base/filter.php'; + +/*! + A Template is initialized with a text file (typically HTML) and can render it + with data from some model. It has a short macro expansion system, equivalent + to PHP short open tags, but usable on all installations. Template caching of + the parsed state is available. +*/ +class Template +{ + /*! @var string The name of the template. */ + protected $template_name = ''; + + /*! @var string The compiled template. */ + protected $data = NULL; + + /*! @var array Variables to provide to the template. */ + protected $vars = array(); + + /*! + Creates a new Template with a given name. + @param string + */ + private function __construct($name) + { + $this->template_name = $name; + } + + static public function NewWithData($data) + { + $template = new Template(''); + $template->data = $template->_ProcessTemplate($data); + return $template; + } + + static public function NewFromCompiledData($data) + { + $template = new Template(''); + $template->data = $data; + return $template; + } + + /*! Gets the name of the template. */ + public function template_name() { return $this->template_name; } + + /*! Gets the parsed data of the template. */ + public function template() { return $this->data; } + + /*! Overload property accessors to set view variables. */ + public function __get($key) + { + return $this->vars[$key]; + } + public function __set($key, $value) + { + $this->vars[$key] = $value; + } + + /*! This includes the template and renders it out. */ + public function Render() + { + $_template = $this->data; + $_vars = $this->vars; + $render = function () use ($_template, $_vars) { + extract($_vars); + eval('?>' . $_template . '<' . '?'); + }; + $render(); + } + + /*! @brief Does any pre-processing on the template. + This performs the macro expansion. The language is very simple and is merely + shorthand for the PHP tags. + + The most common thing needed in templates is string escaped output from an + expression. HTML entities are automatically escaped in this format: +

Hello, {% $user->name %}!

+ + To specify the type to format, you use the pipe symbol and then one of the + following types: str (default; above), int, float, raw. +

Hello, {% %user->name | str %}

+

Hello, user #{% $user->user_id | int %}

+ + To evaluate a non-printing expression, simply add a '!' before the first '%': + {!% if (!$user->user_id): %}

Hello, Guest!

{!% endif %} + + @param string Raw template data + @return string Executable PHP + */ + protected function _ProcessTemplate($data) + { + // The parsed output as compiled PHP. + $processed = ''; + + // If processing a macro, this contains the contents of the macro while + // it is being extracted from the template. + $macro = ''; + $in_macro = FALSE; + + $length = strlen($data); + $i = 0; // The current position of the iterator. + $looking_for_end = FALSE; // Whehter or not an end tag is expected. + $line_number = 1; // The current line number. + $column_number = 0; // The current column number. + + while ($i < $length) { + // See how far the current position is from the end of the string. + $delta = $length - $i; + + // When a new line is reached, update the counters. + if ($data[$i] == "\n") { + ++$line_number; + $column_number = 0; + } + + // Check for simple PHP short-tag expansion. + if ($delta >= 3 && substr($data, $i, 3) == '{!%') { + // If an expansion has already been opened, then it's an error to nest. + if ($looking_for_end) + throw new TemplateException("Unexpected start of expansion at line $line_number:$column_number"); + + $looking_for_end = TRUE; + $processed .= '<' . '?php'; + $i += 3; + continue; + } + // Check for either the end tag or the start of a macro expansion. + else if ($delta >= 2) { + $substr = substr($data, $i, 2); + // Check for an end tag. + if ($substr == '%}') { + // If an end tag was encountered without an open tag, that's an error. + if (!$looking_for_end) + throw new TemplateException("Unexpected end of expansion at line $line_number:$column_number"); + + // If this is a macro, it's time to process it. + if ($in_macro) + $processed .= $this->_ProcessMacro($macro); + + $looking_for_end = FALSE; + $in_macro = FALSE; + $processed .= ' ?>'; + $i += 2; + continue; + } + // Check for the beginning of a macro. + else if ($substr == '{%') { + $processed .= '<' . '?php echo '; + $macro = ''; + $in_macro = TRUE; + $looking_for_end = TRUE; + $i += 2; + continue; + } + } + + // All other characters go into a storage bin. If currently in a macro, + // save off the data separately for parsing. + if ($in_macro) + $macro .= $data[$i]; + else + $processed .= $data[$i]; + ++$i; + } + + return $processed; + } + + /*! + Takes the contents of a macro |{% $some_var | int %}|, which is the part in + between the open and close brackets (excluding '%') and transforms it into a + PHP statement. + */ + protected function _ProcessMacro($macro) + { + // The pipe operator specifies how to sanitize the output. + $formatter_pos = strrpos($macro, '|'); + + // No specifier defaults to escaped string. + if ($formatter_pos === FALSE) + return 'hoplite\\base\\filter\\String(' . $macro . ')'; + + // Otherwise, apply the right filter. + $formatter = trim(substr($macro, $formatter_pos + 1)); + $function = ''; + switch (strtolower($formatter)) { + case 'int': $function = 'Int'; break; + case 'float': $function = 'Float'; break; + case 'str': $function = 'String'; break; + case 'raw': $function = 'RawString'; break; + default: + throw new TemplateException('Invalid macro formatter "' . $formatter . '"'); + } + + // Now get the expression and return a PHP statement. + $expression = trim(substr($macro, 0, $formatter_pos)); + return 'hoplite\\base\\filter\\' . $function . '(' . $expression . ')'; + } +} + +class TemplateException extends \Exception {} -- 2.43.5