Building a Custom PHP Template Engine, Part 4: Control Structures
Today we are going to take a look at control structures and how they fit into our custom template engine. If you haven’t done so already, you should familiarize yourself with our previously related articles to get caught up to speed:
- Building a Custom PHP Template Engine, Part 1
- Building a Custom PHP Template Engine, Part 2: Caching and Compiling
- Building a Custom PHP Template Engine, Part 3: Resource Types
A control structure depicts the flow of code in your script or program. Control structures can be conditional statements: if, elseif, and else; they can be loops: for, foreach, while, and do-while; they can be function calls and more. We are going to alter our Template class to support these with our own syntax. Much like our variable syntax, {$varname}, our control structures should also start and end with curly braces. Most of our control structures, however, will require an ending tag, and will encapsulate a block of code or text inside. For instance: {if $this->GetVar(‘varname’) == ‘test’} this is encapsulated {/if}. Our template modifications will support the following structures: if, elseif, else, for, foreach, while, do-while, php code blocks, and php function calls.
Some Examples
The For Loop:
{for $i = 0; $i < 5; $i++}
<p>{$i} is an {if $i % 2 == 0}even{else}odd{/if} number</p>
{/for}
The Result:
--
<p>0 is an even number</p>
<p>1 is an odd number</p>
<p>2 is an even number</p>
<p>3 is an odd number</p>
<p>4 is an even number</p>
<p>5 is an odd number</p> |
The For-Each Loop:
{foreach array('Name'=>'PHP Professional', 'Occupation'=>'PHP Software Engineer', 'Hobby'=>'Blogging') as $key => $value}
<p>{$key} = {$value}</p>
{/foreach}
The Result:
--
<p>Name = PHP Professional</p>
<p>Occupation = PHP Software Engineer</p>
<p>Hobby = Blogging</p> |
Using Continue and Break:
{for $i = 0; $i < 10; $i++}
{if $i == 2}
<p>continue on 2</p>
{continue}
{/if}
<p>the current iteration is {$i}</p>
{if $i == 5}
<p>it is time to break!</p>
{break}
{/if}
{/for}
The Result:
--
<p>the current iteration is 0</p>
<p>the current iteration is 1</p>
<p>continue on 2</p>
<p>the current iteration is 3</p>
<p>the current iteration is 4</p>
<p>the current iteration is 5</p>
<p>it is time to break!</p |
If you look closely, you should notice that the normal syntax applies. With for-loops, where PHP wants for ($i = 0; $i < 10; $i++) { ...code... } you say in your template, {for $i = 0; $i < 10; $i++} ...code... {/for}. With if-conditions, where PHP wants if ( condition ) { ...code... } you say in your template, {if condition} ...code... {/if}. Going about it this way makes the search and replace method that much easier. Any valid php code is valid, only the way you declare the control structure is different.
Modifications
In order to use control structures in our templates, we need to change the way the engine parses and returns data. Mainly, each template needs to be compiled and then executed. This is required because the control structures need to be changed into PHP syntax. The only way to get PHP to run the syntax is to execute the file. We can execute the file by including it with output buffering, just like we do for normally compiled files. So the changes to our Template class will be a result of this.
Our ‘Parse’ method becomes ‘Compile’, since it’s always compiled and is no longer just ‘parsed’. Any parsed or cached version of our template is the result of the executed compiled version. Our new ‘Compile’ method needs to use regular expressions to find and replace the template control structures into PHP syntax. We need to be careful with this though, we want to search the content as little as possible. For this reason, we provide an additional boolean flag that controls whether or not the class should parse control structures. There may be cases where you know that there are no control structures, or don’t want to allow control structures at all, and in those situations, the class should not search for them. The boolean flag we will provide is, $template->parse_control_structures, and this is true by default. We also have compiled templates in place already, so the class only compiles once the template file is created and after it gets modified. If there is a compiled version already available, we just execute that file instead of searching and replacing again. With these changes, it means that you no longer have the option to compile or not compile the template. The template is always compiled, so the boolean flag $template->compiling gets removed. You still have control over whether or not the templates get cached, via $template->caching.
Our ‘Fetch’ method changes slightly, in order to accomodate the new ‘compile-always’ changes, and some method names change to avoid confusion: CompileTemplate and CacheTemplate become SaveCompiledTemplate and SaveCachedTemplate. Otherwise, everything else stays the same.
It’s important to point out that, if you wish to refer to a variable which has been assigned via AssignVar in your control structures, like an if statement, or a while loop, you need to refer to it via, $this->GetVar(‘varname’). The compiled version is executed within the scope of the ‘Fetch’ method, and so ‘$this’, when used in your template, refers to the instantiated template object which is fetching the template. If you wish to refer to a global variable, use $GLOBALS['varname']. For instance, {if $this->GetVar(‘somevar’) == ‘this value’} do this {else} do that {/if} OR {if $GLOBALS['my_global_var'] == true} do this {/if} OR {if isset($_SESSION['idUser'])} show this to logged in users {else} show a you must log in message {/if}.
I’ve pulled out the ‘Compile’ function, so you can look at the regular expressions before looking at the rest of the code:
public function Compile( $str ) { // replace assigned variables first $search = array(); $replace = array(); foreach ( $this->variables as $key => $value ) { $search[] = '{$'. $key .'}'; $replace[] = '<?php echo $this->GetVar("'. addslashes($key) .'");?>'; } $str = str_replace($search, $replace, $str); // if we're not parsing control structures, there's no need to continue, return the parsed str if ( !$this->parse_control_structures ) return $str; // replace control-structures, predefined blocks and tags (break, continue), other variables that were not assigned before-hand, and other misc. functions $replaced = preg_replace( array( // find PHP blocks '/\{php\}(.*?)\{\/php\}/s' , // find loops and loop-controls like continue and break '/\{(for|foreach|while)\s([^\}]+)\}(.*?)\{\/(for|foreach|while)\s*\}/s' , '/\{dowhile\s([^\}]+)\}(.*?)\{\/dowhile\s*\}/s' , '/\{break\s*\}/' , '/\{continue\s*\}/' , // find if-elseif-else and endif statements '/\{if\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{elseif[^\}]+\}|\{else\s*\})/s' , '/\{elseif\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{else\s*\})/s' , '/\{else\s*\}(.*?)(\{\/if\s*\})/s' , '/\{\/if\s*\}/' , // find variables '/\{\$([^\}|\:]+)\}/' , // find php functions {func_name arg, list} - {var_dump array(1,2,3)} '/\{([^\s\}$]+)\s?(.*?)\}/' ) , array( // put PHP code inside php tags '<?php \1 ?>' , // replace loop template syntax with PHP template syntax '<?php \1(\2):?> \3 <?php end\1;?>' , '<?php do{?> \2 <?php }while(\1);?>' , // replace break and continue '<?php break;?>' , '<?php continue;?>' , // replace if statements '<?php if (\1):?>\2\3' , '<?php elseif (\1):?>\2\3' , '<?php else:?>\1\2' , '<?php endif;?>' , // replace variables, but allow vars that have not been assigned via AssignVar() so we use isset() to check '<?php echo (isset($\1)?$\1:$this->GetVar("\1"));?>' , // replace PHP functions - {var_dump array(1,2,3)} becomes <?php var_dump(array(1,2,3));?> '<?php \1(\2);?>' ) , $str ); // if the regular expression succeeded, use the replaced content, otherwise, use $str which has our pre-assigned variables replaced return $replaced : $replaced : $str; } |
The Class
Here is our modified class:
<?php class Template { /** * TEMPLATE_DIR is a constant which holds the path to the directory where the original template files are stored * * @var string path to template directory **/ const TEMPLATE_DIR = 'TemplateEngine/templates'; /** * COMPILE_DIR is a constant which holds the path to the directory where the compiled template files are stored * * @var string path to compiled directory **/ const COMPILE_DIR = 'TemplateEngine/templates_compiled'; /** * CACHE_DIR is a constant which holds the path to the directory where the cached template files are stored * * @var string path to cached directory **/ const CACHE_DIR = 'TemplateEngine/templates_cached'; /** * RESOURCE_DIR is a constant which holds the path to the directory where resource types may be declared in files * * @var string path to the directory where pre-defined template resource types are stored */ const RESOURCE_DIR = 'TemplateEngine/template_resource_types'; /** * This stores the resource types that have been loaded from files in the pre-determined location (self::RESOURCE_DIR). * This is here so we don't have to look for them on each instantiation, only the first of each page request * * @var array */ private static $LoadedResourceTypes = array(); /** * This is an associative array which holds the variable names (keys) and the values to parse in the template file * * @var array - associative array **/ private $variables = array(); /** * Associative array of resource-types that have been registered with this class * * @var array - array('type_name'=>array('content_callback_function','datestamp_callback_function')) */ private $resource_types = array(); /** * This is a boolean indicating whether or not this class should cache templates or use cached templates when fetched - false by default * * @var boolean false by default **/ public $caching = false; /** * This is a boolean indicating whether or not this class should re-compile the template if it is already compiled, regardless of its datestamps * * @var boolean **/ public $recompile = false; /** * This is a boolean indicating whether or not this class should re-cache the template if it is already cached, regardless of its datestamps * * @var boolean */ public $recache = false; /** * This controls whether or not the template class should look for and replace template control structure syntax into php syntax - true by default * If you know you're not going to use control-structures, turn this off to save processing time * * @var boolean true to parse control structures, false otherwise - true by default */ public $parse_control_structures = true; /** * Our constructor method - this will load resource types from files and handle any other procedures we may need * */ public function __construct() { // load pre-defined resource types from files $this->LoadResourceTypes(); // register our default 'file' resource type $this->AddResourceType('file', array($this, 'FetchFileContent'), array($this, 'FetchFileDatetime')); } /** * This loads resource types from files in self::RESOURCE_DIR * * @return boolean */ private function LoadResourceTypes() { // only read the directory once per request if ( !empty(self::$LoadedResourceTypes) ) { $this->resource_types = self::$LoadedResourceTypes; return true; } // make sure we have a valid directory if ( !is_dir(self::RESOURCE_DIR) ) return false; // read all files in the directory if ( $dir = opendir(self::RESOURCE_DIR) ) { while ( false !== ($file = readdir($dir)) ) { if ( !is_dir(self::RESOURCE_DIR .'/'. $file) ) { // use pathinfo to retrieve the filename without the extension - introduced in PHP 5.2.0 $filename = pathinfo($file, PATHINFO_FILENAME); /** * execute the file to retrieve the callback functions * the function names must follow a specific naming convention * * content functions - function filename_template_content( $template_name ) { } - should return the template content or (bool)false * datetime functions - function filename_template_datetime( $template_name ) { } - should return the last-modified time as a unix-timestamp or (bool)false */ // use output buffering so nothing outputs to the browser ob_start(); include self::RESOURCE_DIR .'/'. $file; ob_end_clean(); // make sure the callback functions exist if ( !is_callable($filename .'_template_content') ) { throw new Exception($file .' does not have a valid callback function for fetching content.'); continue; } if ( !is_callable($filename .'_template_datetime') ) { throw new Exception($file .' does not have a valid callback function for fetching a last-modified date.'); continue; } // save the resource type $this->resource_types[ $filename ] = self::$LoadedResourceTypes[ $filename ] = array($filename .'_template_content', $filename .'_template_datetime'); } } closedir($dir); } return true; } /** * Display a fetched template - uses 'print' * * @param string $name the name of the template to display * @param string $type the resource type we are using to fetch the template - defaults to 'file' */ public function Display( $name , $type='file' ) { $contents = $this->Fetch($name, $type); print $contents; } /** * Fetch a template * * @param string $name the filename of the template to fetch * @param string $type the resource type - defaults to 'file' * @return mixed (bool)false or string contents of the parsed file */ public function Fetch( $name , $type='file' ) { // fetch the last-modified time of the template here, so it's not done multiple times $template_mtime = $this->FetchDatetime($name, $type); if ( $this->caching && !$this->recache && $this->IsCached($name, $type, $template_mtime) ) return file_get_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php'); if ( $this->recompile || !$this->IsCompiled($name, $type, $template_mtime) ) { $contents = $this->FetchContent($name, $type); if ( !$contents ) { throw new Exception('Contents for '. $name .' could not be fetched.'); return false; } // we always compile the template if it's not compiled already $compiled = $this->Compile($contents); $this->SaveCompiledTemplate($name, $compiled, $type); unset($compiled); unset($contents); } // make sure the template doesn't overwrite our $type and $name variables - since it's executed within this scope $__template_name__ = $name; $__template_type__ = $type; unset($name); unset($type); // our 'parsed' version of the template is the result of the executed 'compiled' file ob_start(); include self::COMPILE_DIR .'/'. $__template_type__ .'_'. $__template_name__ .'.php'; $parsed = ob_get_clean(); if ( $this->caching ) $this->SaveCachedTemplate($__template_name__, $parsed, $__template_type__); return $parsed; } /** * Retrieve the content for a registered resource type * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @return mixed the file contents or (bool)false */ public function FetchContent( &$name , $type ) { if ( !isset($this->resource_types[$type]) ) return false; $content = call_user_func($this->resource_types[$type][0], $name); return $content ? $content : false; } /** * Retrieve the last-modified unix-timestamp for a registered resource type * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @return mixed (int)last-modified as a unix-timestamp or (bool)false */ public function FetchDatetime( &$name , $type ) { if ( !isset($this->resource_types[$type]) ) return false; $datetime = call_user_func($this->resource_types[$type][1], $name); return $datetime ? $datetime : false; } /** * Retrieve the content for a 'file' template type - this is for our default resource type * * @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension * @return mixed the file contents or (bool)false */ public function FetchFileContent( &$name ) { $name = trim($name, '/'); if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) ) { $ext = pathinfo($name, PATHINFO_EXTENSION); if ( empty($ext) ) { if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') ) return false; $name = $name .'.tpl'; } } return file_get_contents(self::TEMPLATE_DIR .'/'. $name); } /** * Retrieve the last-modified date for a 'file' template type - this is for our default resource type * * @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension * @return mixed the last-modified date as a unix-timestamp or (bool)false */ public function FetchFileDatetime( &$name ) { $name = trim($name, '/'); if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) ) { $ext = pathinfo($name, PATHINFO_EXTENSION); if ( empty($ext) ) { if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') ) return false; $name = $name .'.tpl'; } } return filemtime(self::TEMPLATE_DIR .'/'. $name); } /** * See if a template-file is compiled already * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @param string $type the resource type - defaults to 'file' * @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function * @return boolean */ public function IsCompiled( &$name , $type='file' , $template_mtime=null ) { if ( is_null($template_mtime) ) $template_mtime = $this->FetchDatetime($name, $type); if ( $template_mtime === false ) return false; if ( !file_exists(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php') ) return false; if ( false === ($compiled_mtime = filemtime(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php')) ) return false; return ($compiled_mtime >= $template_mtime); } /** * See if a template is cached already * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @param string $type the resource type - defaults to 'file' * @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function * @return boolean */ public function IsCached( &$name , $type='file' , $template_mtime=null ) { if ( is_null($template_mtime) ) $template_mtime = $this->FetchDatetime($name, $type); if ( $template_mtime === false ) return false; if ( !file_exists(self::CACHE_DIR .'/'. $type .'_'. $name .'.php') ) return false; if ( false === ($cached_mtime = filemtime(self::CACHE_DIR .'/'. $type .'_'. $name .'.php')) ) return false; return ($cached_mtime >= $template_mtime); } /** * Compile a template * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @param string $contents should contain the compiled contents of the file * @param string $type the resource type - defaults to 'file' * @param boolean $is_compiled if true, $contents need to contain the compiled contents, if false, $contents should contain the raw-contents (uncompiled) * @return boolean */ protected function SaveCompiledTemplate( &$name , $contents , $type='file' , $is_compiled=true ) { if ( !is_dir(self::COMPILE_DIR) ) return false; $compiled = $is_compiled ? $contents : $this->Compile($contents); return (bool)file_put_contents(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php', $compiled); } /** * Cache a template * * @param string $name the template name - this is by reference, because the callback may alter it slightly * @param string $contents should contain the parsed contents of the file * @param string $type the resource type - defaults to 'file' * @return boolean */ protected function SaveCachedTemplate( &$name , $contents , $type='file' ) { if ( !is_dir(self::CACHE_DIR) ) return false; return (bool)file_put_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php', $contents); } /** * Add / Register a resource type with this Template instance * * @param string $name the name of the resource type * @param callback $content_callback the function used to fetch the content * @param callback $datetime_callback the function used to fetch the last-modified time * @return boolean */ public function AddResourceType( $name , $content_callback , $datetime_callback ) { if ( !is_callable($content_callback) ) { throw new Exception('Your content callback function does not appear to be valid for "'. $name .'".'); return false; } if ( !is_callable($datetime_callback) ) { throw new Exception('Your last-modified callback function does not appear to be valid for "'. $name .'".'); return false; } $this->resource_types[ $name ] = array($content_callback, $datetime_callback); return true; } /** * Remove a registered resource type by name * * @param string $name the resource type name you wish to remove */ public function RemoveResourceType( $name ) { if ( isset($this->resource_types[$name]) ) unset($this->resource_types[$name]); } /** * Retrieve a registered resource type * * @param mixed $name if null or not provided, all resource types are returned * @return mixed array or (bool)false */ public function GetResourceType( $name=null ) { if ( is_null($name) ) return $this->resource_types; elseif ( isset($this->resource_types[$name]) ) return $this->resource_types[$name]; else return false; } /** * Assign a variable to the template engine so it can be parsed * * @param string $name the variable name * @param mixed $value */ public function AssignVar( $name , $value ) { $this->variables[$name] = $value; } /** * Retrieve the value of an assigned variable * * @param mixed $name the variable name - if this is not provided or is null, all the variables are returned as an associative array * @return mixed */ public function GetVar( $name=null ) { if ( is_null($name) ) return $this->variables; elseif ( isset($this->variables[$name]) ) return $this->variables[$name]; else return false; } /** * Remove an assigned variable * * @param string $name the variable name */ public function RemoveVar( $name ) { if ( isset($this->variables[$name]) ) unset($this->variables[$name]); } /** * Takes template-contents and returns contents with the necessary PHP code to execute everything, if-statements and variables * * @param string $str the raw template contents * @return string */ public function Compile( $str ) { // replace assigned variables first $search = array(); $replace = array(); foreach ( $this->variables as $key => $value ) { $search[] = '{$'. $key .'}'; $replace[] = '<?php echo $this->GetVar("'. addslashes($key) .'");?>'; } $str = str_replace($search, $replace, $str); // if we're not parsing control structures, there's no need to continue, return the parsed str if ( !$this->parse_control_structures ) return $str; // replace control-structures, predefined blocks and tags (break, continue), other variables that were not assigned before-hand, and other misc. functions $replaced = preg_replace( array( // find PHP blocks '/\{php\}(.*?)\{\/php\}/s' , // find loops and loop-controls like continue and break '/\{(for|foreach|while)\s([^\}]+)\}(.*?)\{\/(for|foreach|while)\s*\}/s' , '/\{dowhile\s([^\}]+)\}(.*?)\{\/dowhile\s*\}/s' , '/\{break\s*\}/' , '/\{continue\s*\}/' , // find if-elseif-else and endif statements '/\{if\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{elseif[^\}]+\}|\{else\s*\})/s' , '/\{elseif\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{else\s*\})/s' , '/\{else\s*\}(.*?)(\{\/if\s*\})/s' , '/\{\/if\s*\}/' , // find variables '/\{\$([^\}|\:]+)\}/' , // find php functions {func_name arg, list} - {var_dump array(1,2,3)} '/\{([^\s\}$]+)\s?(.*?)\}/' ) , array( // put PHP code inside PHP tags '<?php \1 ?>' , // replace loop template syntax with PHP template syntax '<?php \1(\2):?> \3 <?php end\1;?>' , '<?php do{?> \2 <?php }while(\1);?>' , // replace break and continue '<?php break;?>' , '<?php continue;?>' , // replace if statments '<?php if (\1):?>\2\3' , '<?php elseif (\1):?>\2\3' , '<?php else:?>\1\2' , '<?php endif;?>' , // replace variables, but allow vars that have not been assigned via AssignVar() so we use isset() to check '<?php echo (isset($\1)?$\1:$this->GetVar("\1"));?>' , // replace PHP functions - {var_dump array(1,2,3)} becomes var_dump(array(1,2,3)); '<?php \1(\2);?>' ) , $str ); // return the compiled data return $replaced ? $replaced : $str; } } |
View: Source