3 MiniSkel - Flexible content-template parser
4 Based on SPIP Skeletons, see http://www.spip.net/
5 Developed by BohwaZ - http://bohwaz.net/
7 * December 2007 - Initial release
9 This program is free software: you can redistribute it and/or modify
10 it under the terms of the GNU General Public License as published by
11 the Free Software Foundation, either version 3 of the License, or
12 (at your option) any later version.
14 This program is distributed in the hope that it will be useful,
15 but WITHOUT ANY WARRANTY; without even the implied warranty of
16 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 GNU General Public License for more details.
19 You should have received a copy of the GNU General Public License
20 along with this program. If not, see <http://www.gnu.org/licenses/>.
24 * This class manages technical and general exceptions
26 class miniSkelException
extends Exception
28 protected $tpl_filename = '';
30 public function __construct($msg, $file='')
32 $this->tpl_filename
= $file;
33 parent
::__construct($msg);
36 public function getTemplateFilename()
38 return $this->tpl_filename
;
43 * This class only manages markup exceptions
45 class miniSkelMarkupException
extends miniSkelException
52 * The path where templates belongs
54 public $template_path = './';
57 * You can change the name of the loop tag, by default it's BOUCLE, to be compatible with SPIP syntax
58 * be warned that your old templates using <BOUCLE...> syntax will not work anymore
60 public $loopTagName = 'BOUCLE';
63 * You can change the name of short loop tags, by default it's B (like B in BOUCLE)
65 public $loopShortTagName = 'B';
68 * As by default the loop keywords are in french, you can change them here
70 public $loopKeywords = array(
72 'orderDesc' => 'inverse',
75 'duplicates'=> 'doublons',
79 public $includeTagName = 'INCLURE';
82 * Throw exceptions for warnings ? (bad criterias, modifiers that don't exists, etc.)
84 public $strictMode = true;
87 * For internal use : name of the current loop
89 protected $currentLoop = "Unknown";
92 * For internal use : file name of current template
94 protected $currentTemplate = '';
97 * For internal use : avoid kloops and bad templates
99 protected $parentLoopLevel = 0;
100 protected $loopCounter = 0;
103 * Internal global variables, like in smarty's assign
105 protected $variables = array();
108 * Here we save for each loop the variables they have
109 * (It's for pre and post optional content of conditional variables)
110 * Like here : [#NAME, (#ADDRESS)]
112 protected $loopVariables = array();
115 * External modifiers, like in smarty
117 protected $modifiers = array();
122 const ACTION_ORDER_BY
= 1;
123 const ACTION_ORDER_DESC
= 2;
124 const ACTION_AVOID_DUPLICATES
= 3;
125 const ACTION_LIMIT
= 4;
126 const ACTION_MATCH_FIELD
= 5;
127 const ACTION_MATCH_FIELD_BY_VALUE
= 6;
128 const ACTION_MATCH_FIELD_BY_REGEXP
= 7;
129 const ACTION_MATCH_FIELD_NOT_BY_REGEXP
= 8;
130 const ACTION_MATCH_FIELD_IN
= 9;
131 const ACTION_DISPLAY_SEPARATOR
= 10;
136 const LOOP_CONTENT
= 1;
137 const PRE_CONTENT
= 2;
138 const POST_CONTENT
= 3;
139 const ALT_CONTENT
= 4;
142 * Variables context (inside or outside a loop)
144 const CONTEXT_IN_LOOP
= 1;
145 const CONTEXT_GLOBAL
= 2;
146 const CONTEXT_IN_ARG
= 3;
147 const CONTEXT_IN_PRE
= 4;
148 const CONTEXT_IN_POST
= 5;
151 * Replace first occurence of string
153 protected function replaceFirst($search, $replace, $subject)
155 $pos = strpos($subject, $search);
159 $subject = substr_replace($subject, $replace, $pos, strlen($search));
166 * Internal parsing of common loop criterias (tries to be compatible with SPIP syntax)
167 * You can't extend this method
169 * @param string $criteria The unparsed criteria
171 private function parseCriteria($criteria)
173 $criteria = trim($criteria);
175 // {inverse} -> ORDER BY ... DESC
176 if (strtolower($criteria) == $this->loopKeywords
['orderDesc'])
179 'action' => self
::ACTION_ORDER_DESC
,
182 // {doublons} -> avoid duplicates in a page
183 elseif (preg_match('/^('.$this->loopKeywords
['duplicates'].'|'.$this->loopKeywords
['unique'].')\s*([a-z0-9_-]+)?$/i', $criteria))
186 'action' => self
::ACTION_AVOID_DUPLICATES
,
187 'name' => isset($match[2]) ?
$match[2] : false,
190 // {par id_article} -> ORDER BY id_article
191 elseif (preg_match('/^'.$this->loopKeywords
['orderBy'].'\s+([a-z0-9_-]+)$/i', $criteria, $match))
194 'action' => self
::ACTION_ORDER_BY
,
195 'field' => $match[1],
198 // {0,10} -> LIMIT 0,10
199 elseif (preg_match('/^([0-9]+),([0-9]+)$/', $criteria, $match))
202 'action' => self
::ACTION_LIMIT
,
203 'begin' => (int) $match[1],
204 'number' => isset($match[2]) ?
(int) $match[2] : false,
207 // begin_list,20 -> LIMIT {$_GET['begin_list']},20
208 elseif (preg_match('/^('.$this->loopKeywords
['begin'].'_[a-z0-9_-]+)(,([0-9]+))?$/i', $criteria, $match))
210 if (isset($_REQUEST[$match[1]]))
212 $begin = (int) $_REQUEST[$match[1]];
219 if (isset($match[2]) && isset($match[3]))
221 $number = (int) $match[3];
229 'action' => self
::ACTION_LIMIT
,
234 // {id_article} -> WHERE id_article = "{$id_article}" (???)
235 elseif (preg_match('/^([a-z0-9_-]+)$/i', $criteria, $match))
238 'action' => self
::ACTION_MATCH_FIELD
,
239 'field' => $match[1],
242 // {id_article=5} -> WHERE id_article = 5
243 elseif (preg_match('/^([a-z0-9_-]+)\s*(>=|<=|=|!=|>|<)\s*"?(.*?)"?$/i', $criteria, $match))
246 'action' => self
::ACTION_MATCH_FIELD_BY_VALUE
,
247 'field' => $match[1],
248 'comparison'=> $match[2],
249 'value' => $match[3],
252 // {titre==^France} -> WHERE id_article REGEXP "^France"
253 elseif (preg_match('/^([a-z0-9_-]+)\s*(==|!==)\s*"?(.+)"?$/i', $criteria, $match))
256 'action' => ($match[2] == '==') ? self
::ACTION_MATCH_FIELD_BY_REGEXP
: self
::ACTION_MATCH_FIELD_NOT_BY_REGEXP
,
257 'field' => $match[1],
258 'value' => $match[3],
261 // {pays IN "Japon", "France"} -> WHERE pays IN "Japon", "France"
262 elseif (preg_match('/^([a-z0-9_-]+)\s+IN\s+(.+)$/i', $criteria, $match))
264 $content = explode(',', $match[2]);
267 foreach ($content as $item)
269 $item = preg_replace('/^["\']?(.*)["\']?$/', '\\1', $item);
276 'action' => self
::ACTION_MATCH_FIELD_IN
,
277 'field' => $match[1],
281 // {"<br />"} -> Inserts a <br /> between each loop iteration
282 elseif (preg_match('/^"(.+)"$/', $criteria, $match))
285 'action' => self
::ACTION_DISPLAY_SEPARATOR
,
286 'value' => $match[1],
291 throw new miniSkelMarkupException("Unknown criteria '".$criteria."' in ".$this->currentLoop
." loop.", $this->currentTemplate
);
298 * Internal parsing of loops (tries to be compatible with SPIP)
299 * You can't extend this method
301 * @param string $content
302 * @param string $parentLoop
304 private function parseLoops($content, $parentLoop=false)
308 $this->parentLoopLevel++
;
310 // This is a security to keep your server cool
311 if ($this->parentLoopLevel
> 10)
313 throw new miniSkelException("Too many imbricated loops !", $this->currentTemplate
);
317 while (preg_match('/<'.$this->loopTagName
.'([_-][.a-z0-9_-]+|[0-9]+)\s*\(([a-z0-9_-]+)\)\s*(\{.*?\})*>/Ui', $content, $match))
319 if ($this->loopCounter
> 100)
321 throw new miniSkelException("Too many loops for one template !", $this->currentTemplate
);
325 $loopName = $match[1];
326 $loopType = strtolower($match[2]);
327 $loopTag = $match[0];
329 $loopContent = false;
331 $postContent = false;
334 $this->currentLoop
= $loopName;
336 $loopCriterias = array();
338 if (!empty($match[3]))
340 preg_match_all('/\{(.*)\}/U', $match[3], $match, PREG_SET_ORDER
);
342 foreach ($match as $item)
344 $loopCriterias[] = $this->parseCriteria($item[1]);
348 if (preg_match('/<\/'.$this->loopTagName
.$loopName.'>/i', $content, $match_end))
350 $loopTagEnd = $match_end[0];
354 throw new miniSkelMarkupException("Loop tag ".$loopName." is not closed properly.", $this->currentTemplate
);
357 unset($match, $match_end);
359 $loopB = strpos($content, $loopTag);
360 $loopE = strpos($content, $loopTagEnd);
363 $tagE = $loopE +
strlen($loopTagEnd);
367 throw new miniSkelMarkupException("Loop tag ".$loopName." was closed before it was opened ?!", $this->currentTemplate
);
370 // Extract the loop content
371 $loopContent = substr($content, $loopB +
strlen($loopTag), $loopE - $loopB - strlen($loopTag));
373 // The things before the loop (if any)
374 $loopShortTagName = '<'.$this->loopShortTagName
.$loopName.'>';
375 $preB = strpos($content, $loopShortTagName);
379 throw new miniSkelMarkupException("Can't open ".$loopShortTagName." after ".$loopTag."...", $this->currentTemplate
);
384 $preContent = substr($content, $preB +
strlen($loopShortTagName), $tagB - $preB - strlen($loopShortTagName));
387 unset($preB, $loopShortTagName);
389 // After the loop (if any)
390 $loopShortEndTagName = '</'.$this->loopShortTagName
.$loopName.'>';
391 $postE = strpos($content, $loopShortEndTagName);
393 if ($postE !== false && $postE < $loopE)
395 throw new miniSkelMarkupException("Can't close ".$loopShortEndTagName." before ".$loopTagEnd."...", $this->currentTemplate
);
398 if ($postE !== false)
400 $postContent = substr($content, $tagE, $postE - $tagE);
401 $tagE = $postE +
strlen($loopShortEndTagName);
403 unset($postE, $loopShortEndTagName);
406 $loopAltTagName = '<//'.$this->loopShortTagName
.$loopName.'>';
407 $altE = strpos($content, $loopAltTagName);
409 if ($altE !== false && $altE < $tagE)
411 throw new miniSkelMarkupException("Can't close ".$loopAltTagName." before ".$loopTagEnd."...", $this->currentTemplate
);
416 $altContent = substr($content, $tagE, $altE - $tagE);
417 $tagE = $altE +
strlen($loopAltTagName);
420 unset($loopShortEndTagName, $loopAltTagName, $loopShortTagName, $loopB, $loopE, $altE, $postE, $preB);
422 $tagContent = $this->processLoop($loopName, $loopType, $loopCriterias,
423 $loopContent, $preContent, $postContent, $altContent);
425 $content = substr($content, 0, $tagB) . $tagContent . substr($content, $tagE);
427 unset($altContent, $postContent, $preContent, $loopContent, $tagContent, $tagB, $tagE);
429 $this->loopCounter++
;
430 $this->currentLoop
= false;
435 $this->currentLoop
= $parentLoop;
436 $this->parentLoopLevel
--;
443 * Internal parsing of variables
444 * You can't extend this method
446 * @param string $content
447 * @param array $variables
448 * @param int $context (Constant)
450 protected function parseVariables($content, $variables=false, $context=self
::CONTEXT_IN_LOOP
)
452 // This is used for parsing variables in pre or post-content of variables
453 if (!$variables && $context != self
::CONTEXT_IN_LOOP
&& !empty($this->loopVariables
))
455 $variables = $this->loopVariables
;
459 '!(\[([^\[\]]*)\(#([A-Z_]+)(\*)?(\|([^\)]+)*)*\)([^\[\]]*)\]|#([A-Z_-]+))!', $content, $match, PREG_SET_ORDER
);
461 foreach ($match as $item)
463 $tagName = !empty($item[3]) ?
strtolower($item[3]) : strtolower($item[8]);
465 if ($tagName == 'rem')
468 $content = $this->replaceFirst($item[0], '', $content);
472 if ($variables && !array_key_exists($tagName, $variables))
474 //throw new miniSkelMarkupException("Unknow tag '".$tagName."' in loop '".$this->currentLoop."'.");
477 $value = isset($variables[$tagName]) ?
$variables[$tagName] : false;
478 $applyDefault = empty($item[4]) ?
true : false;
479 $modifiers = array();
481 if (!empty($item[3]))
483 $pre = trim($item[2]) ?
$this->parseVariables($item[2], $variables, self
::CONTEXT_IN_PRE
) : $item[2];
484 $post = trim($item[7]) ?
$this->parseVariables($item[7], $variables, self
::CONTEXT_IN_PRE
) : $item[7];
488 $pre = $post = false;
491 if (!empty($item[6]))
493 $modifiers = explode('|', $item[6]);
494 foreach ($modifiers as &$modifier)
496 preg_match('/^([0-9a-z_><!=?-]+)(\{(.*)\})?$/i', $modifier, $match_mod);
498 if (!isset($match_mod[1]))
500 throw new miniSkelMarkupException("Invalid modifier syntax: ".$modifier);
503 $modifier = array('name' => $match_mod[1], 'arguments' => array());
505 if (isset($match_mod[3]))
507 preg_match_all('/["\']?([^"\',]+)["\']?/', $match_mod[3], $match_args, PREG_SET_ORDER
);
508 foreach ($match_args as $arg)
510 $arg = trim($arg[1]);
511 $modifier['arguments'][] = $arg ?
$this->parseVariables($arg, $variables, self
::CONTEXT_IN_ARG
) : $arg;
517 $content = $this->replaceFirst($item[0], $this->processVariable($tagName, $value, $applyDefault, $modifiers, $pre, $post, $context), $content);
519 unset($modifiers, $item, $match_mod, $match_args, $tagName, $applyDefault, $pre, $post, $value);
525 protected function parseIncludes($content)
527 preg_match_all('/<'.$this->includeTagName
.'\{(.*)\}>/U', $content, $match, PREG_SET_ORDER
);
532 foreach ($match as $m)
534 $m_args = explode(',', $m[1]);
537 foreach ($m_args as $m_arg)
539 $m_arg = trim($m_arg);
540 $m_arg = explode('=', $m_arg);
541 $args[trim($m_arg[0])] = isset($m_arg[1]) ?
trim($m_arg[1]) : true;
544 $content = $this->replaceFirst($m[0], $this->processInclude($args), $content);
547 unset($m_arg, $args, $m, $match);
552 * Here we call modifiers
553 * It's just a standard method doing simple things
554 * You're encouraged to rewrite this method to suit your needs
556 protected function callModifier($name, $value, $args=false)
558 $method_name = 'variableModifier_'.$name;
560 // We can use internal methods as modifiers
561 if (method_exists($this, $method_name))
563 $value = $this->$method_name($value, $args);
566 // Are external functions or objects
567 elseif (isset($this->modifiers
[$name]))
569 $value = call_user_func($this->modifiers
[$name], $value, $args);
572 // Default is just an escape, but you can change this
573 elseif ($name == 'default')
575 $value = htmlspecialchars($value, ENT_QUOTES
);
578 // Strict mode throw an exception here if we try to use an undefined modifier
579 elseif ($this->strictMode
)
581 throw new miniSkelMarkupException("Modifier '".$name."' isn't defined in loop '".$this->currentLoop
."'.");
588 * Here we process the loop
589 * This is somehow basic, but a good example
590 * You're encouraged to extend this method to suit your needs
592 protected function processLoop($loopName, $loopType, $loopCriterias, $loopContent, $preContent, $postContent, $altContent)
596 // We can call an internal method (use extends !) to match the loop type
597 $method_name = 'processLoopType_' . $loopType;
599 if (!method_exists($this, $method_name))
601 throw new miniSkelException("There is no known '".$loopType."' loop type.");
604 $loopContent = $this->$method_name($loopCriterias, $loopContent);
606 // If the loop isn't empty (!=false)
609 // we put the pre-content before the loop content
612 $out .= $this->parse($preContent, $loopName, self
::PRE_CONTENT
);
615 $out .= $loopContent;
617 // we put the post-content after the loop content
620 $out .= $this->parse($postContent, $loopName, self
::POST_CONTENT
);
624 // If the loop is empty and we have an alternate content we show it
629 $out .= $this->parse($altContent, $loopName, self
::ALT_CONTENT
);
637 * Here we process a single variable
638 * You're encouraged to extend this method to suit your needs
640 * @param string $name
641 * @param string $value
642 * @param bool $applyDefault Apply the default modifier ?
643 * @param array $modifiers Modifiers to apply
644 * @param string $pre Optional pre-content
645 * @param string $post Optional $post-content
646 * @param bool $context Variable context (may be self::CONTEXT_GLOBAL or self::CONTEXT_IN_LOOP)
648 protected function processVariable($name, $value, $applyDefault, $modifiers, $pre, $post, $context)
650 // If $value == false it seems it's not set in the variables array used in the loop,
651 // so maybe it's a global variable that we want (but you can change this)
652 if ($value === false && isset($this->variables
[$name]))
654 $value = $this->variables
[$name];
657 // The applyDefault bit is used here to apply a modifier, but you can use it for some other things
659 $value = $this->callModifier('default', $value);
661 // We process modifiers
662 foreach ($modifiers as &$modifier)
664 $value = $this->callModifier($modifier['name'], $value, $modifier['arguments']);
667 // It's important to put this here, because we can have tricky things like:
668 // [(#TITLE|orIfEmpty{"Empty title"})]
669 // where the orIfEmpty modifier will replace the $value with "Empty title" if $value is empty
670 // so $value is not empty anymore after the modifier call
678 // Getting pre-content
680 $out .= $this->parseVariables($pre, false, $context);
684 // Getting post-content
686 $out .= $this->parseVariables($post, false, $context);
692 * Processing an include instruction
694 protected function processInclude($args)
697 throw new miniSkelMarkupException($this->includeTagName
. ' requires at least an argument');
700 return $this->fetch($file);
704 * Parsing a text section for loops and global variables
705 * You're encouraged to rewrite this method to suit your needs
707 * @param string $content
708 * @param string $parent The parent loop, if this function is called inside a loop
709 * @param string $content_type The content type, like self::LOOP_CONTENT and others
711 protected function parse($content, $parent=false, $content_type=false)
713 $content = $this->parseIncludes($content);
714 $content = $this->parseLoops($content, $parent);
715 $content = $this->parseVariables($content, $this->variables
, self
::CONTEXT_GLOBAL
);
720 * Like in smarty we can assign global variables in the template
722 public function assign($name, $value)
724 $this->variables
[$name] = $value;
728 * Like in smarty we can register external modifiers
730 public function register_modifier($name, $function)
732 $this->modifiers
[$name] = $function;
736 * Returns the parsed template file $template
738 public function fetch($template)
740 $this->currentTemplate
= $template;
741 $template = file_get_contents($this->template_path
. $template);
742 return $this->parse($template);
746 * Displays the parsed template file $template
748 public function display($template)
750 echo $this->fetch($template);