xref: /plugin/yalist/syntax.php (revision bb527396e55a6437db1f9e730db8b8c2a3780f55)
1<?php
2
3use dokuwiki\Parsing\Handler;
4use dokuwiki\Extension\SyntaxPlugin;
5
6/*
7 * This plugin extends DokuWiki's list markup syntax to allow definition lists
8 * and list items with multiple paragraphs. The complete syntax is as follows:
9 *
10 *
11 *   - ordered list item            [<ol><li>]  <!-- as standard syntax -->
12 *   * unordered list item          [<ul><li>]  <!-- as standard syntax -->
13 *   ? definition list term         [<dl><dt>]
14 *   : definition list definition   [<dl><dd>]
15 *
16 *   -- ordered list item w/ multiple paragraphs
17 *   ** unordered list item w/ multiple paragraphs
18 *   :: definition list definition w/multiple paragraphs
19 *   .. new paragraph in --, **, or ::
20 *
21 *
22 * Lists can be nested within lists, just as in the standard DokuWiki syntax.
23 *
24 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
25 * @author     Ben Slusky <sluskyb@paranoiacs.org>
26 *
27 * @phpcs:disable Squiz.Classes.ValidClassName.NotPascalCase
28 */
29class syntax_plugin_yalist extends SyntaxPlugin
30{
31    private static array $odt_table_stack = [];
32    private static int $odt_table_stack_index = 0;
33    private array $stack = [];
34
35    public function getType()
36    {
37        return 'container';
38    }
39
40    public function getSort()
41    {
42        // just before listblock (10)
43        return 9;
44    }
45
46    public function getPType()
47    {
48        return 'block';
49    }
50
51    public function getAllowedTypes()
52    {
53        return ['substition', 'protected', 'disabled', 'formatting'];
54    }
55
56    public function connectTo($mode)
57    {
58        $this->Lexer->addEntryPattern('\n {2,}(?:--?|\*\*?|\?|::?)', $mode, 'plugin_yalist');
59        $this->Lexer->addEntryPattern('\n\t{1,}(?:--?|\*\*?|\?|::?)', $mode, 'plugin_yalist');
60        $this->Lexer->addPattern('\n {2,}(?:--?|\*\*?|\?|::?|\.\.)', 'plugin_yalist');
61        $this->Lexer->addPattern('\n\t{1,}(?:--?|\*\*?|\?|::?|\.\.)', 'plugin_yalist');
62    }
63
64    public function postConnect()
65    {
66        $this->Lexer->addExitPattern('\n', 'plugin_yalist');
67    }
68
69    public function handle($match, $state, $pos, Handler $handler)
70    {
71        $output = [];
72        $level  = 0;
73        switch ($state) {
74            case DOKU_LEXER_ENTER:
75                $frame = $this->interpretMatch($match);
76                $level = $frame['level'] = 1;
77                $output[] = "{$frame['list']}_open";
78                $output[] = "{$frame['item']}_open";
79                $output[] = "{$frame['item']}_content_open";
80                if ($frame['paras']) {
81                    $output[] = 'p_open';
82                }
83                $this->stack[] = $frame;
84                break;
85            case DOKU_LEXER_EXIT:
86                $close_content = true;
87                while ($frame = array_pop($this->stack)) {
88                    // for the first frame we pop off the stack, we'll need to
89                    // close the content tag; for the rest it will have been
90                    // closed already
91                    if ($close_content) {
92                        if ($frame['paras']) {
93                            $output[] = 'p_close';
94                        }
95                        $output[] = "{$frame['item']}_content_close";
96                        $close_content = false;
97                    }
98                    $output[] = "{$frame['item']}_close";
99                    $output[] = "{$frame['list']}_close";
100                }
101                break;
102            case DOKU_LEXER_MATCHED:
103                $last_frame = end($this->stack);
104                if (str_ends_with($match, '..')) {
105                    // new paragraphs cannot be deeper than the current depth,
106                    // but they may be shallower
107                    $para_depth    = count(explode('  ', str_replace("\t", '  ', $match)));
108                    $close_content = true;
109                    while ($para_depth < $last_frame['depth'] && count($this->stack) > 1) {
110                        if ($close_content) {
111                            if ($last_frame['paras']) {
112                                $output[] = 'p_close';
113                            }
114                            $output[] = "{$last_frame['item']}_content_close";
115                            $close_content = false;
116                        }
117                        $output[] = "{$last_frame['item']}_close";
118                        $output[] = "{$last_frame['list']}_close";
119                        array_pop($this->stack);
120                        $last_frame = end($this->stack);
121                    }
122                    if ($last_frame['paras']) {
123                        if ($close_content) {
124                            // depth did not change
125                            $output[] = 'p_close';
126                            $output[] = 'p_open';
127                        } else {
128                            $output[] = "{$last_frame['item']}_content_open";
129                            $output[] = 'p_open';
130                        }
131                    } else {
132                        // let's just pretend we didn't match...
133                        $state  = DOKU_LEXER_UNMATCHED;
134                        $output = $match;
135                    }
136                    break;
137                }
138                $curr_frame = $this->interpretMatch($match);
139                if ($curr_frame['depth'] > $last_frame['depth']) {
140                    // going one level deeper
141                    $level = $last_frame['level'] + 1;
142                    if ($last_frame['paras']) {
143                        $output[] = 'p_close';
144                    }
145                    $output[] = "{$last_frame['item']}_content_close";
146                    $output[] = "{$curr_frame['list']}_open";
147                } else {
148                    // same depth, or getting shallower
149                    $close_content = true;
150                    // keep popping frames off the stack until we find a frame
151                    // that's at least as deep as this one, or until only the
152                    // bottom frame (i.e. the initial list markup) remains
153                    while (
154                        $curr_frame['depth'] < $last_frame['depth'] &&
155                        count($this->stack) > 1
156                    ) {
157                        // again, we need to close the content tag only for
158                        // the first frame popped off the stack
159                        if ($close_content) {
160                            if ($last_frame['paras']) {
161                                $output[] = 'p_close';
162                            }
163                            $output[] = "{$last_frame['item']}_content_close";
164                            $close_content = false;
165                        }
166                        $output[] = "{$last_frame['item']}_close";
167                        $output[] = "{$last_frame['list']}_close";
168                        array_pop($this->stack);
169                        $last_frame = end($this->stack);
170                    }
171                    // pull the last frame off the stack;
172                    // it will be replaced by the current frame
173                    array_pop($this->stack);
174                    $level = $last_frame['level'];
175                    if ($close_content) {
176                        if ($last_frame['paras']) {
177                            $output[] = 'p_close';
178                        }
179                        $output[] = "{$last_frame['item']}_content_close";
180                    }
181                    $output[] = "{$last_frame['item']}_close";
182                    if ($curr_frame['list'] != $last_frame['list']) {
183                        // change list types
184                        $output[] = "{$last_frame['list']}_close";
185                        $output[] = "{$curr_frame['list']}_open";
186                    }
187                }
188                // and finally, open tags for the new list item
189                $output[] = "{$curr_frame['item']}_open";
190                $output[] = "{$curr_frame['item']}_content_open";
191                if ($curr_frame['paras']) {
192                    $output[] = 'p_open';
193                }
194                $curr_frame['level'] = $level;
195                $this->stack[] = $curr_frame;
196                break;
197            case DOKU_LEXER_UNMATCHED:
198                $output = $match;
199                break;
200        }
201        return ['state' => $state, 'output' => $output, 'level' => $level];
202    }
203
204    private function interpretMatch($match)
205    {
206        $tag_table = ['*' => 'u_li', '-' => 'o_li', '?' => 'dt', ':' => 'dd'];
207        $tag       = $tag_table[substr($match, -1)];
208        return ['depth' => count(explode('  ', str_replace("\t", '  ', $match))),
209            'list'  => substr($tag, 0, 1) . 'l', 'item'  => substr($tag, -2),
210            'paras' => (substr($match, -1) === substr($match, -2, 1))];
211    }
212
213    public function render($format, Doku_Renderer $renderer, $data)
214    {
215        if (!in_array($format, ['xhtml', 'latex', 'odt'])) {
216            return false;
217        }
218        if ($data['state'] == DOKU_LEXER_UNMATCHED) {
219            if ($format != 'odt') {
220                $renderer->doc .= $renderer->_xmlEntities($data['output']);
221            } else {
222                $renderer->cdata($data['output']);
223            }
224            return true;
225        }
226        foreach ($data['output'] as $i) {
227            switch ($format) {
228                case 'xhtml':
229                    $this->renderXhtmlItem($renderer, $i, $data);
230                    break;
231                case 'latex':
232                    $this->renderLatexItem($renderer, $i, $data);
233                    break;
234                case 'odt':
235                    $this->renderOdtItem($renderer, $i, $data);
236                    break;
237            }
238        }
239        if ($data['state'] == DOKU_LEXER_EXIT) {
240            if ($format != 'odt') {
241                $renderer->doc .= "\n";
242            } else {
243                $renderer->linebreak();
244            }
245        }
246        return true;
247    }
248
249    private function renderXhtmlItem(Doku_Renderer $renderer, $item, $data)
250    {
251        $markup = '';
252        switch ($item) {
253            case 'ol_open':
254                $markup = "<ol>\n";
255                break;
256            case 'ol_close':
257                $markup = "</ol>\n";
258                break;
259            case 'ul_open':
260                $markup = "<ul>\n";
261                break;
262            case 'ul_close':
263                $markup = "</ul>\n";
264                break;
265            case 'dl_open':
266                $markup = "<dl>\n";
267                break;
268            case 'dl_close':
269                $markup = "</dl>\n";
270                break;
271            case 'li_open':
272                $markup = "<li class=\"level{$data['level']}\">";
273                break;
274            case 'li_content_open':
275                $markup = "<div class=\"li\">\n";
276                break;
277            case 'li_content_close':
278            case 'dd_content_close':
279                $markup = "\n</div>";
280                break;
281            case 'li_close':
282                $markup = "</li>\n";
283                break;
284            case 'dt_open':
285                $markup = "<dt class=\"level{$data['level']}\">";
286                break;
287            case 'dt_content_open':
288                $markup = "<span class=\"dt\">";
289                break;
290            case 'dt_content_close':
291                $markup = "</span>";
292                break;
293            case 'dt_close':
294                $markup = "</dt>\n";
295                break;
296            case 'dd_open':
297                $markup = "<dd class=\"level{$data['level']}\">";
298                break;
299            case 'dd_content_open':
300                $markup = "<div class=\"dd\">\n";
301                break;
302            case 'dd_close':
303                $markup = "</dd>\n";
304                break;
305            case 'p_open':
306                $markup = "<p>\n";
307                break;
308            case 'p_close':
309                $markup = "\n</p>";
310                break;
311        }
312        $renderer->doc .= $markup;
313    }
314
315    private function renderLatexItem(Doku_Renderer $renderer, $item)
316    {
317        $markup = '';
318        switch ($item) {
319            case 'ol_open':
320                $markup = "\\begin{enumerate}\n";
321                break;
322            case 'ol_close':
323                $markup = "\\end{enumerate}\n";
324                break;
325            case 'ul_open':
326                $markup = "\\begin{itemize}\n";
327                break;
328            case 'ul_close':
329                $markup = "\\end{itemize}\n";
330                break;
331            case 'dl_open':
332                $markup = "\\begin{description}\n";
333                break;
334            case 'dl_close':
335                $markup = "\\end{description}\n";
336                break;
337            case 'li_open':
338                $markup = "\item ";
339                break;
340            case 'li_content_open':
341            case 'li_content_close':
342            case 'dt_content_close':
343            case 'dd_content_close':
344                break;
345            case 'li_close':
346            case 'p_close':
347                $markup = "\n";
348                break;
349            case 'dt_open':
350                $markup = "\item[";
351                break;
352            case 'dt_content_open':
353            case 'dt_close':
354                $markup = "] ";
355                break;
356            case 'dd_open':
357            case 'dd_content_open':
358            case 'dd_close':
359            case 'p_open':
360        }
361        $renderer->doc .= $markup;
362    }
363
364    /**
365     * Render yalist items for ODT format
366     *
367     * @param Doku_Renderer $renderer The current renderer object
368     * @param string        $item     The item to render
369     *
370     * @author LarsDW223
371     */
372    private function renderOdtItem(Doku_Renderer $renderer, $item)
373    {
374        switch ($item) {
375            case 'ol_open':
376                $renderer->listo_open();
377                break;
378            case 'ul_open':
379                $renderer->listu_open();
380                break;
381            case 'dl_open':
382                if ($this->getConf('def_list_odt_export') != 'table') {
383                    $renderer->listu_open();
384                } else {
385                    $renderer->table_open(2);
386                }
387                self::$odt_table_stack [self::$odt_table_stack_index]             = [];
388                self::$odt_table_stack [self::$odt_table_stack_index]['itemOpen'] = false;
389                self::$odt_table_stack [self::$odt_table_stack_index]['dtState']  = 0;
390                self::$odt_table_stack [self::$odt_table_stack_index]['ddState']  = 0;
391                self::$odt_table_stack_index++;
392                break;
393            case 'ol_close':
394            case 'ul_close':
395                $renderer->list_close();
396                break;
397            case 'dl_close':
398                $config = $this->getConf('def_list_odt_export');
399                if ($config != 'table') {
400                    if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] != 2) {
401                        if ($config == 'listheader' && method_exists($renderer, 'listheader_close')) {
402                            $renderer->listheader_close();
403                        } else {
404                            $renderer->listitem_close();
405                        }
406                    }
407                    self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] = 0;
408                    $renderer->list_close();
409                } else {
410                    if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] == 0) {
411                        $properties            = [];
412                        $properties ['border'] = 'none';
413                        $renderer->_odtTableCellOpenUseProperties($properties);
414                        $renderer->tablecell_close();
415                    }
416                    self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] = 0;
417                    if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
418                        $renderer->tablerow_close(1);
419                        self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
420                    }
421                    $renderer->table_close();
422                }
423                if (self::$odt_table_stack_index > 0) {
424                    self::$odt_table_stack_index--;
425                    unset(self::$odt_table_stack [self::$odt_table_stack_index]);
426                }
427                break;
428
429            case 'li_open':
430                $renderer->listitem_open(1);
431                break;
432            case 'li_content_open':
433                $renderer->listcontent_open();
434                break;
435            case 'li_content_close':
436                $renderer->listcontent_close();
437                break;
438            case 'li_close':
439                $renderer->listitem_close();
440                break;
441
442            case 'dt_open': // unconditional: DT tags can't contain paragraphs. That would not be legal XHTML.
443                switch ($this->getConf('def_list_odt_export')) {
444                    case 'listheader':
445                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
446                            if (method_exists($renderer, 'listheader_close')) {
447                                $renderer->listheader_close();
448                            } else {
449                                $renderer->listitem_close();
450                            }
451                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
452                        }
453                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
454                            if (method_exists($renderer, 'listheader_open')) {
455                                $renderer->listheader_open(1);
456                            } else {
457                                $renderer->listitem_open(1);
458                            }
459                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
460                        }
461                        break;
462                    case 'table':
463                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] == 0) {
464                            $properties            = [];
465                            $properties ['border'] = 'none';
466                            $renderer->_odtTableCellOpenUseProperties($properties);
467                            $renderer->tablecell_close();
468                        }
469
470                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
471                            $renderer->tablerow_close();
472                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
473                        }
474                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
475                            $renderer->tablerow_open(1);
476                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
477                        }
478                        $properties            = [];
479                        $properties ['border'] = 'none';
480                        $renderer->_odtTableCellOpenUseProperties($properties);
481                        break;
482                    default:
483                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
484                            $renderer->listitem_close();
485                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
486                        }
487                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
488                            $renderer->listitem_open(1);
489                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
490                        }
491                        break;
492                }
493                self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] = 1;
494                self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] = 0;
495                break;
496            case 'dd_open':
497                switch ($this->getConf('def_list_odt_export')) {
498                    case 'listheader':
499                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
500                            if (method_exists($renderer, 'listheader_open')) {
501                                $renderer->listheader_open(1);
502                            } else {
503                                $renderer->listitem_open(1);
504                            }
505                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
506                        }
507                        break;
508                    case 'table':
509                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
510                            $renderer->tablerow_open(1);
511                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
512                        }
513                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] == 1) {
514                            $renderer->tablecell_close();
515                        }
516                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] == 0) {
517                            $properties            = [];
518                            $properties ['border'] = 'none';
519                            $renderer->_odtTableCellOpenUseProperties($properties);
520                            $renderer->tablecell_close();
521                        }
522
523                        $properties            = [];
524                        $properties ['border'] = 'none';
525                        $renderer->_odtTableCellOpenUseProperties($properties);
526                        break;
527                    default:
528                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === false) {
529                            $renderer->listitem_open(1);
530                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = true;
531                        }
532                        break;
533                }
534                self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] = 0;
535                self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] = 1;
536                break;
537            case 'dt_content_open':
538                match ($this->getConf('def_list_odt_export')) {
539                    'table' => $renderer->p_open(),
540                    default => $renderer->listcontent_open(),
541                };
542                $this->renderODTOpenSpan($renderer);
543                break;
544            case 'dd_content_open':
545                match ($this->getConf('def_list_odt_export')) {
546                    'table' => $renderer->p_open(),
547                    default => $renderer->listcontent_open(),
548                };
549                break;
550            case 'dt_content_close':
551                $this->renderODTCloseSpan($renderer);
552                match ($this->getConf('def_list_odt_export')) {
553                    'table' => $renderer->p_close(),
554                    default => $renderer->listcontent_close(),
555                };
556                break;
557            case 'dd_content_close':
558                match ($this->getConf('def_list_odt_export')) {
559                    'table' => $renderer->p_close(),
560                    default => $renderer->listcontent_close(),
561                };
562                break;
563            case 'dt_close':
564                switch ($this->getConf('def_list_odt_export')) {
565                    case 'listheader':
566                    default:
567                        $renderer->linebreak();
568                        break;
569                    case 'table':
570                        $renderer->tablecell_close();
571                        self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] = 2;
572                        break;
573                }
574                break;
575
576            case 'dd_close':
577                switch ($this->getConf('def_list_odt_export')) {
578                    case 'listheader':
579                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
580                            if (method_exists($renderer, 'listheader_close')) {
581                                $renderer->listheader_close();
582                            } else {
583                                $renderer->listitem_close();
584                            }
585                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
586                        }
587                        break;
588                    case 'table':
589                        $renderer->tablecell_close();
590                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
591                            $renderer->tablerow_close(1);
592                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
593                        }
594                        break;
595                    default:
596                        if (self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] === true) {
597                            $renderer->listitem_close(1);
598                            self::$odt_table_stack [self::$odt_table_stack_index - 1]['itemOpen'] = false;
599                        }
600                        break;
601                }
602                self::$odt_table_stack [self::$odt_table_stack_index - 1]['dtState'] = 0;
603                self::$odt_table_stack [self::$odt_table_stack_index - 1]['ddState'] = 2;
604                break;
605
606            case 'p_open':
607                $renderer->p_open();
608                break;
609            case 'p_close':
610                $renderer->p_close();
611                break;
612        }
613    }
614
615    /**
616     * Open ODT span for rendering of dt-content
617     *
618     * @param Doku_Renderer $renderer The current renderer object
619     *
620     * @author LarsDW223
621     */
622    private function renderODTOpenSpan($renderer)
623    {
624        $properties = [];
625
626        // Get CSS properties for ODT export.
627        $renderer->getODTProperties($properties, 'div', 'dokuwiki dt', null);
628
629        $renderer->_odtSpanOpenUseProperties($properties);
630    }
631
632    /**
633     * Close ODT span for rendering of dt-content
634     *
635     * @param Doku_Renderer $renderer The current renderer object
636     *
637     * @author LarsDW223
638     */
639    private function renderODTCloseSpan($renderer)
640    {
641        if (method_exists($renderer, '_odtSpanClose') === false) {
642            // Function is not supported by installed ODT plugin version, return.
643            return;
644        }
645        $renderer->_odtSpanClose();
646    }
647}
648