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