1<?php
2/**
3 * DokuWiki Plugin ExtList (Syntax component)
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Satoshi Sahara <sahara.satoshi@gmail.com>
7 *
8 */
9if (!defined('DOKU_INC')) die();
10
11class syntax_plugin_extlist extends DokuWiki_Syntax_Plugin
12{
13    public function getType()
14    {   // Syntax Type
15        return 'container';
16    }
17
18    public function getAllowedTypes()
19    {   // Allowed Mode Types
20        return array(
21            'formatting',
22            'substition',
23            'disabled',
24            'protected',
25        );
26    }
27
28    public function getPType()
29    {   // Paragraph Type
30        return 'block';
31    }
32
33
34    protected $stack = array();
35    protected $list_class = array(); // store class specified by macro
36
37    // Enable hierarchical numbering for nested ordered lists
38    protected $olist_level = 0;
39    protected $olist_info = array();
40
41    protected $use_div = true;
42
43
44
45    /**
46     * Connect pattern to lexer
47     */
48    protected $mode;
49    protected $macro_pattern;
50    protected $entry_pattern, $match_pattern, $extra_pattern, $exit_pattern;
51
52    public function preConnect()
53    {
54        // drop 'syntax_' from class name
55        $this->mode = substr(get_class($this), 7);
56
57        // macro to specify list class
58        $this->macro_pattern = '\n(?: {2,}|\t{1,})~~(?:dl|ol|ul):[\w -]*?~~';
59
60        // list patterns
61        $olist_pattern    = '\-?\d+[.:] |-:?'; // ordered list item
62        $ulist_pattern    = '\*:?';            // unordered list items
63        $dlist_pattern    = ';;?|::?';         // description list item
64        $continue_pattern = '\+:?';            // continued contents to the previous item
65
66        $this->entry_pattern = '\n(?: {2,}|\t{1,})'.'(?:'
67                       . $olist_pattern .'|'
68                       . $ulist_pattern .'|'
69                       . $dlist_pattern . ') *';
70        $this->match_pattern = '\n(?: {2,}|\t{1,})'.'(?:'
71                       . $olist_pattern .'|'
72                       . $ulist_pattern .'|'
73                       . $dlist_pattern .'|'
74                       . $continue_pattern . ') *';
75
76        // continued item content by indentation
77        $this->extra_pattern = '\n(?: {2,}|\t{1,})(?![-*;:?+~])';
78
79        $this->exit_pattern  = '\n';
80    }
81
82    public function connectTo($mode)
83    {
84        $this->Lexer->addEntryPattern('[ \t]*'.$this->entry_pattern, $mode, $this->mode);
85
86        // macro syntax to specify class for next list [ul|ol|dl]
87        $this->Lexer->addSpecialPattern('[ \t]*'.$this->macro_pattern, $mode, $this->mode);
88        $this->Lexer->addPattern($this->macro_pattern, $this->mode);
89    }
90
91    public function postConnect()
92    {
93        // subsequent list item
94        $this->Lexer->addPattern($this->match_pattern, $this->mode);
95        $this->Lexer->addPattern('  ::? ', $this->mode);  // dt and dd in one line
96
97        // continued list item content, indented by at least two spaces
98        $this->Lexer->addPattern($this->extra_pattern, $this->mode);
99
100        // terminate a list block
101        $this->Lexer->addExitPattern($this->exit_pattern, $this->mode);
102    }
103
104    public function getSort()
105    {   // sort number used to determine priority of this mode
106        return 9; // just before listblock (10)
107    }
108
109    /**
110     * get markup and depth from the match
111     *
112     * @param $match string
113     * @return array
114     */
115    protected function interpret($match)
116    {
117        // depth: count double spaces indent after '\n'
118        $depth = substr_count(str_replace("\t", '  ', ltrim($match,' ')),'  ');
119        $match = trim($match);
120
121        $m = array('depth' => $depth);
122
123        // check order list markup with number
124        if (preg_match('/^(-?\d+)([.:])/', $match, $matches)) {
125            $m += array(
126                    'mk' => ($matches[2] == '.') ? '-' : '-:',
127                    'list' => 'ol', 'item' => 'li', 'num' => $matches[1]
128            );
129            if ($matches[2] == ':') $m += array('p' => 1);
130        } else {
131            $m += array('mk' => $match);
132
133            switch (substr($match, 0, 1)) {
134                case '' :
135                    $m += array('list' => NULL, 'item' => NULL);
136                    break;
137                case '+':
138                    $m += array('list' => NULL, 'item' => NULL);
139                    if ($match == '+:') {
140                        $m += array('p' => 1);
141                    } else {
142                        $m['mk'] = '';
143                    }
144                    break;
145                case '-': // ordered list
146                    $m += array('list' => 'ol', 'item' => 'li');
147                    if ($match == '-:') $m += array('p' => 1);
148                    break;
149                case '*': // unordered list
150                    $m += array('list' => 'ul', 'item' => 'li');
151                    if ($match == '*:') $m += array('p' => 1);
152                    break;
153                case ';': // description list term
154                    $m += array('list' => 'dl', 'item' => 'dt');
155                    if ($match == ';') $m += array('class' => 'compact');
156                    break;
157                case ':': // description list desc
158                    $m += array('list' => 'dl', 'item' => 'dd');
159                    if ($match == '::') $m += array('p' => 1);
160                    break;
161            }
162        }
163        //error_log('extlist intpret: $m='.var_export($m,1));
164        return $m;
165    }
166
167
168    /**
169     * check whether list type has changed
170     */
171    private function isListTypeChanged($m0, $m1)
172    {
173        return (strncmp($m0['list'], $m1['list'], 1) !== 0);
174    }
175
176    /**
177     * create marker for ordered list items
178     */
179    private function olist_marker($level)
180    {
181        $num = $this->olist_info[$level];
182        //error_log('olist lv='.$level.' list_class='.$this->list_class['ol'].' num='.$num);
183
184        // Parenthesized latin small letter marker: ⒜,⒝,⒞, … ,⒵
185        if (strpos($this->list_class['ol'], 'alphabet') !== false){
186            $modulus = ($num -1) % 26;
187            $marker = '&#'.(9372 + $modulus).';';
188            return $marker;
189        }
190
191        // Hierarchical numbering (default): eg. 1. | 2.1 | 3.2.9
192        $marker = $this->olist_info[1];
193        if ($level == 1) {
194            return $marker.'.';
195        } else {
196            for ($i = 2; $i <= $level; $i++) {
197                $marker .= '.'.$this->olist_info[$i];
198            }
199            return $marker;
200        }
201    }
202
203    /**
204     * srore class attribute for lists [ul|ol|dl] specfied by macro pattern
205     * macro_pattern = ~~(?:dl|ol|ul):[\w -]*?~~
206     */
207    private function storeListClass($str)
208    {
209            $str = trim($str);
210            $this->list_class[substr($str,2,2)] = trim(substr($str,5,-2));
211    }
212
213
214    /**
215     * helper function to simplify writing plugin calls to the instruction list
216     * first three arguments are passed to function render as $data
217     * Note: this function was used in the DW exttab3 plugin.
218     */
219    protected function _writeCall($tag, $attr, $state, $pos, $match, $handler)
220    {
221        $handler->addPluginCall($this->getPluginName(),
222            array($state, $tag, $attr), $state, $pos, $match
223        );
224
225    }
226
227    /**
228     * write call to open a list block [ul|ol|dl]
229     */
230    private function _openList($m, $pos, $match, $handler)
231    {
232        $tag = $m['list'];
233        // start value only for ordered list
234        if ($tag == 'ol') {
235            $attr = isset($m['num']) ? 'start="'.$m['num'].'"' : '';
236            $this->olist_level++; // increase olist level
237        } else {
238            $attr = null;
239        }
240        // list class
241        $class = 'extlist';
242        if (isset($this->list_class[$tag])) {
243            // Note: list_class can be empty
244            $class.= ' '.$this->list_class[$tag];
245        } else {
246            $class.= ' '.$this->getConf($tag.'_class');
247        }
248        $class = rtrim($class);
249        $attr.= !empty($attr) ? ' ' : '';
250        $attr.= ' class="'.$class.'"';
251        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
252    }
253
254    /**
255     * write call to close a list block [ul|ol|dl]
256     */
257    private function _closeList($m, $pos, $match, $handler)
258    {
259        $tag = $m['list'];
260        if ($tag == 'ol') {
261            $this->olist_level--; // reduce olist level
262        }
263        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
264    }
265
266    /**
267     * write call to open a list item [li|dt|dd]
268     */
269    private function _openItem($m, $pos, $match, $handler)
270    {
271        $tag = $m['item'];
272        switch ($m['mk']) {
273            case '-':
274            case '-:':
275                // prepare hierarchical marker for nested ordered list item
276                $this->olist_info[$this->olist_level] = $m['num'];
277                $lv = $this->olist_level;
278                $attr = ' value="'.$m['num'].'"';
279                $attr.= ' data-marker="'.$this->olist_marker($lv).'"';
280                break;
281            case ';':
282                $attr = 'class="'.$m['class'].'"';
283                break;
284            default:
285                $attr = '';
286        }
287        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
288    }
289
290    /**
291     * write call to close a list item [li|dt|dd]
292     */
293    private function _closeItem($m, $pos, $match, $handler)
294    {
295        $tag = $m['item'];
296        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
297    }
298
299    /**
300     * write call to open inner wrapper [div|span]
301     */
302    private function _openWrapper($m, $pos, $match, $handler)
303    {
304        switch ($m['mk']) {
305            case ';':  // dl dt
306            case ';;': // dl dt, explicitly no-compact
307                $tag = 'span'; $attr = '';
308                break;
309            case ':':  // dl dd
310            case '::': // dl dd p
311                return;
312                break;
313            default:
314                if (!$this->use_div) return;
315                $tag = 'div';  $attr = 'class="li"';
316                break;
317        }
318        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
319    }
320
321    /**
322     * write call to close inner wrapper [div|span]
323     */
324    private function _closeWrapper($m, $pos, $match, $handler)
325    {
326        switch ($m['mk']) {
327            case ';':  // dl dt
328            case ';;': // dl dt, explicitly no-compact
329                $tag = 'span';
330                break;
331            case ':':  // dl dd
332            case '::': // dl dd p
333                return;
334                break;
335            default:
336                if (!$this->use_div) return;
337                $tag = 'div';
338                break;
339        }
340        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
341    }
342
343    /**
344     * write call to open paragraph (p tag)
345     */
346    private function _openParagraph($pos, $match, $handler)
347    {
348        $this->_writeCall('p','',DOKU_LEXER_ENTER, $pos,$match,$handler);
349    }
350
351    /**
352     * write call to close paragraph (p tag)
353     */
354    private function _closeParagraph($pos, $match, $handler)
355    {
356        $this->_writeCall('p','',DOKU_LEXER_EXIT, $pos,$match,$handler);
357    }
358
359    /**
360     * Handle the match
361     */
362    public function handle($match, $state, $pos, Doku_Handler $handler)
363    {
364        switch ($state) {
365        case DOKU_LEXER_SPECIAL:
366            //  specify class attribute for lists [ul|ol|dl]
367            $this->storeListClass($match);
368            break;
369
370        case DOKU_LEXER_ENTER:
371            $m1 = $this->interpret($match);
372            if (($m1['list'] == 'ol') && !isset($m1['num'])) {
373                $m1['num'] = 1;
374            }
375
376            // open list tag [ul|ol|dl]
377            $this->_openList($m1, $pos,$match,$handler);
378            // open item tag [li|dt|dd]
379            $this->_openItem($m1, $pos,$match,$handler);
380            // open inner wrapper [div|span]
381            $this->_openWrapper($m1, $pos,$match,$handler);
382            // open p if necessary
383            if (isset($m1['p'])) $this->_openParagraph($pos,$match,$handler);
384
385            // add to stack
386            array_push($this->stack, $m1);
387            break;
388
389        case DOKU_LEXER_UNMATCHED:
390            // cdata --- use base() as _writeCall() is prefixed for private/protected
391            $handler->base($match, $state, $pos);
392            break;
393
394        case DOKU_LEXER_EXIT:
395            // clear list_class
396            $this->list_class = array();
397            // do not break here!
398
399        case DOKU_LEXER_MATCHED:
400            //  specify class attribute for lists [ul|ol|dl]
401            if (substr($match, -2) == '~~') {
402                $this->storeListClass($match);
403                break;
404            }
405
406            // retrieve previous list item from stack
407            $m0 = array_pop($this->stack);
408            $m1 = $this->interpret($match);
409
410            // set m1 depth if dt and dd are in one line
411            if (($m1['depth'] == 0) && ($m0['item'] == 'dt')) {
412                $m1['depth'] = $m0['depth'];
413            }
414
415            // continued list item content, indented by at least two spaces
416            if (empty($m1['mk']) && ($m1['depth'] > 0)) {
417                // !!EXPERIMENTAL SCRIPTIO CONTINUA concerns!!
418                // replace indent to single space, but leave it for LineBreak2 plugin
419                $handler->base("\n",  DOKU_LEXER_UNMATCHED, $pos);
420
421                // restore stack
422                array_push($this->stack, $m0);
423                break;
424            }
425
426            // close p if necessary
427            if (isset($m0['p'])) $this->_closeParagraph($pos,$match,$handler);
428
429            // close inner wrapper [div|span] if necessary
430            if ($m1['mk'] == '+:') {
431                // Paragraph markup
432                if ($m0['depth'] > $m1['depth']) {
433                    $this->_closeWrapper($m0, $pos,$match,$handler);
434                } else {
435                    // new paragraph can not be deeper than previous depth
436                    // fix current depth quietly
437                    $m1['depth'] = min($m0['depth'], $m1['depth']);
438                }
439                // fix previous p type
440                $m0['p'] = 1;
441            } else {
442                // List item markup
443                if ($m0['depth'] >= $m1['depth']) {
444                    $this->_closeWrapper($m0, $pos,$match,$handler);
445                }
446            }
447
448            // List item becomes shallower - close deeper list
449            while (isset($m0['depth']) && ($m0['depth'] > $m1['depth'])) {
450                // close item [li|dt|dd]
451                $this->_closeItem($m0, $pos,$match,$handler);
452                // close list [ul|ol|dl]
453                $this->_closeList($m0, $pos,$match,$handler);
454
455                $m0 = array_pop($this->stack);
456            }
457
458            // Break out of switch structure if end of list block
459            if ($state == DOKU_LEXER_EXIT) {
460                break;
461            }
462
463            // Paragraph markup
464            if ($m1['mk'] == '+:') {
465                $this->_openParagraph($pos,$match,$handler);
466                $m1['depth'] = $m0['depth'];
467                $m1 = $m0 + array('p' => 1);
468
469                // restore stack
470                array_push($this->stack, $m1);
471                break;
472            }
473
474            // List item markup
475            if ($m0['depth'] < $m1['depth']) { // list becomes deeper
476                // restore stack
477                array_push($this->stack, $m0);
478
479            } else if ($m0['depth'] == $m1['depth']) {
480                // close item [li|dt|dd]
481                $this->_closeItem($m0, $pos,$match,$handler);
482                // close list [ul|ol|dl] if necessary
483                if ($this->isListTypeChanged($m0, $m1)) {
484                    $this->_closeList($m0, $pos,$match,$handler);
485                    $m0['num'] = 0;
486                }
487            }
488
489            // open list [ul|ol|dl] if necessary
490            if (($m0['depth'] < $m1['depth']) || (isset($m0['num']) && ($m0['num'] === 0))) {
491                if (isset($m1['num']) && !is_numeric($m1['num'])) $m1['num'] = 1;
492                $this->_openList($m1, $pos,$match,$handler);
493            } else {
494                if (isset($m1['num']) && !is_numeric($m1['num'])) $m1['num'] = $m0['num']  +1;
495            }
496
497            // open item [li|dt|dd]
498            $this->_openItem($m1, $pos,$match,$handler);
499            // open inner wrapper [div|span]
500            $this->_openWrapper($m1, $pos,$match,$handler);
501            // open p if necessary
502            if (isset($m1['p'])) $this->_openParagraph($pos,$match,$handler);
503
504            // add to stack
505            array_push($this->stack, $m1);
506
507        } // end of switch
508        return false;
509    }
510
511
512    /**
513     * Create output
514     */
515    public function render($format, Doku_Renderer $renderer, $data)
516    {
517        switch ($format) {
518            case 'xhtml':
519                return $this->render_xhtml($renderer, $data);
520            //case 'latex':
521            //    $latex = $this->loadHelper('extlist_latex');
522            //    return $latex->render($renderer, $data);
523            //case 'odt':
524            //    $odt = $this->loadHelper('extlist_odt');
525            //    return $odt->render($renderer, $data);
526            default:
527                return false;
528        }
529    }
530
531
532    /**
533     * Create xhtml output
534     */
535    protected function render_xhtml(Doku_Renderer $renderer, $data)
536    {
537        list($state, $tag, $attr) = $data;
538        switch ($state) {
539            case DOKU_LEXER_ENTER:   // open tag
540                $renderer->doc.= $this->_open($tag, $attr);
541                break;
542            case DOKU_LEXER_MATCHED: // defensive, shouldn't occur
543            case DOKU_LEXER_UNMATCHED:
544                $renderer->cdata($tag);
545                break;
546            case DOKU_LEXER_EXIT:    // close tag
547                $renderer->doc.= $this->_close($tag);
548                break;
549        }
550        return true;
551    }
552
553    /**
554     * open a tag, a utility for render_xhtml()
555     */
556    protected function _open($tag, $attr = null)
557    {
558        if (!empty($attr)) $attr = ' '.$attr;
559        list($before, $after) = $this->_tag_indent($tag);
560        return $before.'<'.$tag.$attr.'>'.$after;
561    }
562
563    /**
564     * close a tag, a utility for render_xhtml()
565     */
566    protected function _close($tag)
567    {
568        list($before, $after) = $this->_tag_indent('/'.$tag);
569        return $before.'</'.$tag.'>'.$after;
570    }
571
572    /**
573     * prefix and suffix of html tags
574     *
575     * Initialize this array only once instead of each time the method _tag_indent()
576     * is called.
577     */
578    protected $indent = [
579            'ol'  => [NL,NL],   '/ol'  => ['',NL],
580            'ul'  => [NL,NL],   '/ul'  => ['',NL],
581            'dl'  => [NL,NL],   '/dl'  => ['',NL],
582            'li'  => ['  ',''], '/li'  => ['',NL],
583            'dt'  => ['  ',''], '/dt'  => ['',NL],
584            'dd'  => ['  ',NL], '/dd'  => ['',NL],
585            'p'   => [NL,''],   '/p'   => ['',NL],
586            'div' => [NL,''],   '/div' => ['',NL],
587        ];
588
589    /**
590     * indent tags for readability if HTML source
591     *
592     * @param string $tag tag name
593     * @return array
594     */
595    private function _tag_indent($tag)
596    {
597        if (array_key_exists($tag, $this->indent))
598            return $this->indent[$tag];
599
600        return ['',''];
601    }
602
603}
604