init
[garradin.git] / include / libs / miniskel / class.miniskel.php
1 <?php
2 /*
3 MiniSkel - Flexible content-template parser
4 Based on SPIP Skeletons, see http://www.spip.net/
5 Developed by BohwaZ - http://bohwaz.net/
6
7 * December 2007 - Initial release
8
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.
13
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.
18
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/>.
21 */
22
23 /**
24 * This class manages technical and general exceptions
25 */
26 class miniSkelException extends Exception
27 {
28 protected $tpl_filename = '';
29
30 public function __construct($msg, $file='')
31 {
32 $this->tpl_filename = $file;
33 parent::__construct($msg);
34 }
35
36 public function getTemplateFilename()
37 {
38 return $this->tpl_filename;
39 }
40 }
41
42 /**
43 * This class only manages markup exceptions
44 */
45 class miniSkelMarkupException extends miniSkelException
46 {
47 }
48
49 class miniSkel
50 {
51 /**
52 * The path where templates belongs
53 */
54 public $template_path = './';
55
56 /**
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
59 */
60 public $loopTagName = 'BOUCLE';
61
62 /**
63 * You can change the name of short loop tags, by default it's B (like B in BOUCLE)
64 */
65 public $loopShortTagName = 'B';
66
67 /**
68 * As by default the loop keywords are in french, you can change them here
69 */
70 public $loopKeywords = array(
71 'orderBy' => 'par',
72 'orderDesc' => 'inverse',
73 'begin' => 'debut',
74 'random' => 'hasard',
75 'duplicates'=> 'doublons',
76 'unique' => 'unique',
77 );
78
79 public $includeTagName = 'INCLURE';
80
81 /**
82 * Throw exceptions for warnings ? (bad criterias, modifiers that don't exists, etc.)
83 */
84 public $strictMode = true;
85
86 /**
87 * For internal use : name of the current loop
88 */
89 protected $currentLoop = "Unknown";
90
91 /**
92 * For internal use : file name of current template
93 */
94 protected $currentTemplate = '';
95
96 /**
97 * For internal use : avoid kloops and bad templates
98 */
99 protected $parentLoopLevel = 0;
100 protected $loopCounter = 0;
101
102 /**
103 * Internal global variables, like in smarty's assign
104 */
105 protected $variables = array();
106
107 /**
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)]
111 */
112 protected $loopVariables = array();
113
114 /**
115 * External modifiers, like in smarty
116 */
117 protected $modifiers = array();
118
119 /**
120 * Criteria actions
121 */
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;
132
133 /**
134 * Loop content types
135 */
136 const LOOP_CONTENT = 1;
137 const PRE_CONTENT = 2;
138 const POST_CONTENT = 3;
139 const ALT_CONTENT = 4;
140
141 /**
142 * Variables context (inside or outside a loop)
143 */
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;
149
150 /**
151 * Replace first occurence of string
152 */
153 protected function replaceFirst($search, $replace, $subject)
154 {
155 $pos = strpos($subject, $search);
156
157 if ($pos !== false)
158 {
159 $subject = substr_replace($subject, $replace, $pos, strlen($search));
160 }
161
162 return $subject;
163 }
164
165 /**
166 * Internal parsing of common loop criterias (tries to be compatible with SPIP syntax)
167 * You can't extend this method
168 *
169 * @param string $criteria The unparsed criteria
170 */
171 private function parseCriteria($criteria)
172 {
173 $criteria = trim($criteria);
174
175 // {inverse} -> ORDER BY ... DESC
176 if (strtolower($criteria) == $this->loopKeywords['orderDesc'])
177 {
178 return array(
179 'action' => self::ACTION_ORDER_DESC,
180 );
181 }
182 // {doublons} -> avoid duplicates in a page
183 elseif (preg_match('/^('.$this->loopKeywords['duplicates'].'|'.$this->loopKeywords['unique'].')\s*([a-z0-9_-]+)?$/i', $criteria))
184 {
185 return array(
186 'action' => self::ACTION_AVOID_DUPLICATES,
187 'name' => isset($match[2]) ? $match[2] : false,
188 );
189 }
190 // {par id_article} -> ORDER BY id_article
191 elseif (preg_match('/^'.$this->loopKeywords['orderBy'].'\s+([a-z0-9_-]+)$/i', $criteria, $match))
192 {
193 return array(
194 'action' => self::ACTION_ORDER_BY,
195 'field' => $match[1],
196 );
197 }
198 // {0,10} -> LIMIT 0,10
199 elseif (preg_match('/^([0-9]+),([0-9]+)$/', $criteria, $match))
200 {
201 return array(
202 'action' => self::ACTION_LIMIT,
203 'begin' => (int) $match[1],
204 'number' => isset($match[2]) ? (int) $match[2] : false,
205 );
206 }
207 // begin_list,20 -> LIMIT {$_GET['begin_list']},20
208 elseif (preg_match('/^('.$this->loopKeywords['begin'].'_[a-z0-9_-]+)(,([0-9]+))?$/i', $criteria, $match))
209 {
210 if (isset($_REQUEST[$match[1]]))
211 {
212 $begin = (int) $_REQUEST[$match[1]];
213 }
214 else
215 {
216 $begin = $match[1];
217 }
218
219 if (isset($match[2]) && isset($match[3]))
220 {
221 $number = (int) $match[3];
222 }
223 else
224 {
225 $number = false;
226 }
227
228 return array(
229 'action' => self::ACTION_LIMIT,
230 'begin' => $begin,
231 'number' => $number,
232 );
233 }
234 // {id_article} -> WHERE id_article = "{$id_article}" (???)
235 elseif (preg_match('/^([a-z0-9_-]+)$/i', $criteria, $match))
236 {
237 return array(
238 'action' => self::ACTION_MATCH_FIELD,
239 'field' => $match[1],
240 );
241 }
242 // {id_article=5} -> WHERE id_article = 5
243 elseif (preg_match('/^([a-z0-9_-]+)\s*(>=|<=|=|!=|>|<)\s*"?(.*?)"?$/i', $criteria, $match))
244 {
245 return array(
246 'action' => self::ACTION_MATCH_FIELD_BY_VALUE,
247 'field' => $match[1],
248 'comparison'=> $match[2],
249 'value' => $match[3],
250 );
251 }
252 // {titre==^France} -> WHERE id_article REGEXP "^France"
253 elseif (preg_match('/^([a-z0-9_-]+)\s*(==|!==)\s*"?(.+)"?$/i', $criteria, $match))
254 {
255 return array(
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],
259 );
260 }
261 // {pays IN "Japon", "France"} -> WHERE pays IN "Japon", "France"
262 elseif (preg_match('/^([a-z0-9_-]+)\s+IN\s+(.+)$/i', $criteria, $match))
263 {
264 $content = explode(',', $match[2]);
265 $values = array();
266
267 foreach ($content as $item)
268 {
269 $item = preg_replace('/^["\']?(.*)["\']?$/', '\\1', $item);
270 $values[] = $item;
271 }
272
273 unset($content);
274
275 return array(
276 'action' => self::ACTION_MATCH_FIELD_IN,
277 'field' => $match[1],
278 'values' => $values,
279 );
280 }
281 // {"<br />"} -> Inserts a <br /> between each loop iteration
282 elseif (preg_match('/^"(.+)"$/', $criteria, $match))
283 {
284 return array(
285 'action' => self::ACTION_DISPLAY_SEPARATOR,
286 'value' => $match[1],
287 );
288 }
289 else
290 {
291 throw new miniSkelMarkupException("Unknown criteria '".$criteria."' in ".$this->currentLoop." loop.", $this->currentTemplate);
292
293 return $criteria;
294 }
295 }
296
297 /**
298 * Internal parsing of loops (tries to be compatible with SPIP)
299 * You can't extend this method
300 *
301 * @param string $content
302 * @param string $parentLoop
303 */
304 private function parseLoops($content, $parentLoop=false)
305 {
306 if ($parentLoop)
307 {
308 $this->parentLoopLevel++;
309
310 // This is a security to keep your server cool
311 if ($this->parentLoopLevel > 10)
312 {
313 throw new miniSkelException("Too many imbricated loops !", $this->currentTemplate);
314 }
315 }
316
317 while (preg_match('/<'.$this->loopTagName.'([_-][.a-z0-9_-]+|[0-9]+)\s*\(([a-z0-9_-]+)\)\s*(\{.*?\})*>/Ui', $content, $match))
318 {
319 if ($this->loopCounter > 100)
320 {
321 throw new miniSkelException("Too many loops for one template !", $this->currentTemplate);
322 }
323
324 $loopCounter = 0;
325 $loopName = $match[1];
326 $loopType = strtolower($match[2]);
327 $loopTag = $match[0];
328
329 $loopContent = false;
330 $preContent = false;
331 $postContent = false;
332 $altContent = false;
333
334 $this->currentLoop = $loopName;
335
336 $loopCriterias = array();
337
338 if (!empty($match[3]))
339 {
340 preg_match_all('/\{(.*)\}/U', $match[3], $match, PREG_SET_ORDER);
341
342 foreach ($match as $item)
343 {
344 $loopCriterias[] = $this->parseCriteria($item[1]);
345 }
346 }
347
348 if (preg_match('/<\/'.$this->loopTagName.$loopName.'>/i', $content, $match_end))
349 {
350 $loopTagEnd = $match_end[0];
351 }
352 else
353 {
354 throw new miniSkelMarkupException("Loop tag ".$loopName." is not closed properly.", $this->currentTemplate);
355 }
356
357 unset($match, $match_end);
358
359 $loopB = strpos($content, $loopTag);
360 $loopE = strpos($content, $loopTagEnd);
361
362 $tagB = $loopB;
363 $tagE = $loopE + strlen($loopTagEnd);
364
365 if ($loopB > $loopE)
366 {
367 throw new miniSkelMarkupException("Loop tag ".$loopName." was closed before it was opened ?!", $this->currentTemplate);
368 }
369
370 // Extract the loop content
371 $loopContent = substr($content, $loopB + strlen($loopTag), $loopE - $loopB - strlen($loopTag));
372
373 // The things before the loop (if any)
374 $loopShortTagName = '<'.$this->loopShortTagName.$loopName.'>';
375 $preB = strpos($content, $loopShortTagName);
376
377 if ($preB > $loopB)
378 {
379 throw new miniSkelMarkupException("Can't open ".$loopShortTagName." after ".$loopTag."...", $this->currentTemplate);
380 }
381
382 if ($preB !== false)
383 {
384 $preContent = substr($content, $preB + strlen($loopShortTagName), $tagB - $preB - strlen($loopShortTagName));
385 $tagB = $preB;
386 }
387 unset($preB, $loopShortTagName);
388
389 // After the loop (if any)
390 $loopShortEndTagName = '</'.$this->loopShortTagName.$loopName.'>';
391 $postE = strpos($content, $loopShortEndTagName);
392
393 if ($postE !== false && $postE < $loopE)
394 {
395 throw new miniSkelMarkupException("Can't close ".$loopShortEndTagName." before ".$loopTagEnd."...", $this->currentTemplate);
396 }
397
398 if ($postE !== false)
399 {
400 $postContent = substr($content, $tagE, $postE - $tagE);
401 $tagE = $postE + strlen($loopShortEndTagName);
402 }
403 unset($postE, $loopShortEndTagName);
404
405 // alternative
406 $loopAltTagName = '<//'.$this->loopShortTagName.$loopName.'>';
407 $altE = strpos($content, $loopAltTagName);
408
409 if ($altE !== false && $altE < $tagE)
410 {
411 throw new miniSkelMarkupException("Can't close ".$loopAltTagName." before ".$loopTagEnd."...", $this->currentTemplate);
412 }
413
414 if ($altE !== false)
415 {
416 $altContent = substr($content, $tagE, $altE - $tagE);
417 $tagE = $altE + strlen($loopAltTagName);
418 }
419
420 unset($loopShortEndTagName, $loopAltTagName, $loopShortTagName, $loopB, $loopE, $altE, $postE, $preB);
421
422 $tagContent = $this->processLoop($loopName, $loopType, $loopCriterias,
423 $loopContent, $preContent, $postContent, $altContent);
424
425 $content = substr($content, 0, $tagB) . $tagContent . substr($content, $tagE);
426
427 unset($altContent, $postContent, $preContent, $loopContent, $tagContent, $tagB, $tagE);
428
429 $this->loopCounter++;
430 $this->currentLoop = false;
431 }
432
433 if ($parentLoop)
434 {
435 $this->currentLoop = $parentLoop;
436 $this->parentLoopLevel--;
437 }
438
439 return $content;
440 }
441
442 /**
443 * Internal parsing of variables
444 * You can't extend this method
445 *
446 * @param string $content
447 * @param array $variables
448 * @param int $context (Constant)
449 */
450 protected function parseVariables($content, $variables=false, $context=self::CONTEXT_IN_LOOP)
451 {
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))
454 {
455 $variables = $this->loopVariables;
456 }
457
458 preg_match_all(
459 '!(\[([^\[\]]*)\(#([A-Z_]+)(\*)?(\|([^\)]+)*)*\)([^\[\]]*)\]|#([A-Z_-]+))!', $content, $match, PREG_SET_ORDER);
460
461 foreach ($match as $item)
462 {
463 $tagName = !empty($item[3]) ? strtolower($item[3]) : strtolower($item[8]);
464
465 if ($tagName == 'rem')
466 {
467 // discard comments
468 $content = $this->replaceFirst($item[0], '', $content);
469 continue;
470 }
471
472 if ($variables && !array_key_exists($tagName, $variables))
473 {
474 //throw new miniSkelMarkupException("Unknow tag '".$tagName."' in loop '".$this->currentLoop."'.");
475 }
476
477 $value = isset($variables[$tagName]) ? $variables[$tagName] : false;
478 $applyDefault = empty($item[4]) ? true : false;
479 $modifiers = array();
480
481 if (!empty($item[3]))
482 {
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];
485 }
486 else
487 {
488 $pre = $post = false;
489 }
490
491 if (!empty($item[6]))
492 {
493 $modifiers = explode('|', $item[6]);
494 foreach ($modifiers as &$modifier)
495 {
496 preg_match('/^([0-9a-z_><!=?-]+)(\{(.*)\})?$/i', $modifier, $match_mod);
497
498 if (!isset($match_mod[1]))
499 {
500 throw new miniSkelMarkupException("Invalid modifier syntax: ".$modifier);
501 }
502
503 $modifier = array('name' => $match_mod[1], 'arguments' => array());
504
505 if (isset($match_mod[3]))
506 {
507 preg_match_all('/["\']?([^"\',]+)["\']?/', $match_mod[3], $match_args, PREG_SET_ORDER);
508 foreach ($match_args as $arg)
509 {
510 $arg = trim($arg[1]);
511 $modifier['arguments'][] = $arg ? $this->parseVariables($arg, $variables, self::CONTEXT_IN_ARG) : $arg;
512 }
513 }
514 }
515 }
516
517 $content = $this->replaceFirst($item[0], $this->processVariable($tagName, $value, $applyDefault, $modifiers, $pre, $post, $context), $content);
518
519 unset($modifiers, $item, $match_mod, $match_args, $tagName, $applyDefault, $pre, $post, $value);
520 }
521
522 return $content;
523 }
524
525 protected function parseIncludes($content)
526 {
527 preg_match_all('/<'.$this->includeTagName.'\{(.*)\}>/U', $content, $match, PREG_SET_ORDER);
528
529 if (empty($match))
530 return $content;
531
532 foreach ($match as $m)
533 {
534 $m_args = explode(',', $m[1]);
535 $args = array();
536
537 foreach ($m_args as $m_arg)
538 {
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;
542 }
543
544 $content = $this->replaceFirst($m[0], $this->processInclude($args), $content);
545 }
546
547 unset($m_arg, $args, $m, $match);
548 return $content;
549 }
550
551 /**
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
555 */
556 protected function callModifier($name, $value, $args=false)
557 {
558 $method_name = 'variableModifier_'.$name;
559
560 // We can use internal methods as modifiers
561 if (method_exists($this, $method_name))
562 {
563 $value = $this->$method_name($value, $args);
564 }
565
566 // Are external functions or objects
567 elseif (isset($this->modifiers[$name]))
568 {
569 $value = call_user_func($this->modifiers[$name], $value, $args);
570 }
571
572 // Default is just an escape, but you can change this
573 elseif ($name == 'default')
574 {
575 $value = htmlspecialchars($value, ENT_QUOTES);
576 }
577
578 // Strict mode throw an exception here if we try to use an undefined modifier
579 elseif ($this->strictMode)
580 {
581 throw new miniSkelMarkupException("Modifier '".$name."' isn't defined in loop '".$this->currentLoop."'.");
582 }
583
584 return $value;
585 }
586
587 /**
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
591 */
592 protected function processLoop($loopName, $loopType, $loopCriterias, $loopContent, $preContent, $postContent, $altContent)
593 {
594 $out = '';
595
596 // We can call an internal method (use extends !) to match the loop type
597 $method_name = 'processLoopType_' . $loopType;
598
599 if (!method_exists($this, $method_name))
600 {
601 throw new miniSkelException("There is no known '".$loopType."' loop type.");
602 }
603
604 $loopContent = $this->$method_name($loopCriterias, $loopContent);
605
606 // If the loop isn't empty (!=false)
607 if ($loopContent)
608 {
609 // we put the pre-content before the loop content
610 if ($preContent)
611 {
612 $out .= $this->parse($preContent, $loopName, self::PRE_CONTENT);
613 }
614
615 $out .= $loopContent;
616
617 // we put the post-content after the loop content
618 if ($postContent)
619 {
620 $out .= $this->parse($postContent, $loopName, self::POST_CONTENT);
621 }
622 }
623
624 // If the loop is empty and we have an alternate content we show it
625 else
626 {
627 if ($altContent)
628 {
629 $out .= $this->parse($altContent, $loopName, self::ALT_CONTENT);
630 }
631 }
632
633 return $out;
634 }
635
636 /**
637 * Here we process a single variable
638 * You're encouraged to extend this method to suit your needs
639 *
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)
647 */
648 protected function processVariable($name, $value, $applyDefault, $modifiers, $pre, $post, $context)
649 {
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]))
653 {
654 $value = $this->variables[$name];
655 }
656
657 // The applyDefault bit is used here to apply a modifier, but you can use it for some other things
658 if ($applyDefault)
659 $value = $this->callModifier('default', $value);
660
661 // We process modifiers
662 foreach ($modifiers as &$modifier)
663 {
664 $value = $this->callModifier($modifier['name'], $value, $modifier['arguments']);
665 }
666
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
671 if (empty($value))
672 {
673 return '';
674 }
675
676 $out = '';
677
678 // Getting pre-content
679 if ($pre)
680 $out .= $this->parseVariables($pre, false, $context);
681
682 $out .= $value;
683
684 // Getting post-content
685 if ($post)
686 $out .= $this->parseVariables($post, false, $context);
687
688 return $out;
689 }
690
691 /**
692 * Processing an include instruction
693 */
694 protected function processInclude($args)
695 {
696 if (empty($args))
697 throw new miniSkelMarkupException($this->includeTagName . ' requires at least an argument');
698
699 $file = key($args);
700 return $this->fetch($file);
701 }
702
703 /**
704 * Parsing a text section for loops and global variables
705 * You're encouraged to rewrite this method to suit your needs
706 *
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
710 */
711 protected function parse($content, $parent=false, $content_type=false)
712 {
713 $content = $this->parseIncludes($content);
714 $content = $this->parseLoops($content, $parent);
715 $content = $this->parseVariables($content, $this->variables, self::CONTEXT_GLOBAL);
716 return $content;
717 }
718
719 /**
720 * Like in smarty we can assign global variables in the template
721 */
722 public function assign($name, $value)
723 {
724 $this->variables[$name] = $value;
725 }
726
727 /**
728 * Like in smarty we can register external modifiers
729 */
730 public function register_modifier($name, $function)
731 {
732 $this->modifiers[$name] = $function;
733 }
734
735 /**
736 * Returns the parsed template file $template
737 */
738 public function fetch($template)
739 {
740 $this->currentTemplate = $template;
741 $template = file_get_contents($this->template_path . $template);
742 return $this->parse($template);
743 }
744
745 /**
746 * Displays the parsed template file $template
747 */
748 public function display($template)
749 {
750 echo $this->fetch($template);
751 }
752 }
753
754 ?>