1<?php 2 3 4use ComboStrap\AnalyticsDocument; 5use ComboStrap\Bootstrap; 6use ComboStrap\CallStack; 7use ComboStrap\LogUtility; 8use ComboStrap\PageH1; 9use ComboStrap\PluginUtility; 10use ComboStrap\TagAttributes; 11 12 13if (!defined('DOKU_INC')) die(); 14 15/** 16 * Class syntax_plugin_combo_heading 17 * Heading HTML super set 18 * 19 * It contains also all heading utility class 20 * 21 */ 22class syntax_plugin_combo_heading extends DokuWiki_Syntax_Plugin 23{ 24 25 26 const TAG = "heading"; 27 const OLD_TITLE_TAG = "title"; // old tag 28 const TAGS = [self::TAG, self::OLD_TITLE_TAG]; 29 30 const LEVEL = 'level'; 31 const DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID = "display-bs-4"; 32 const ALL_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6", "d1", "d2", "d3", "d4", "d5", "d6"]; 33 const DISPLAY_TYPES = ["d1", "d2", "d3", "d4", "d5", "d6"]; 34 const DISPLAY_TYPES_ONLY_BS_5 = ["d5", "d6"]; // only available in 5 35 36 /** 37 * An heading may be printed 38 * as outline and should be in the toc 39 */ 40 const TYPE_OUTLINE = "outline"; 41 const HEADING_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6"]; 42 /** 43 * The attribute that holds only the text of the heading 44 * (used to create the id and the text in the toc) 45 */ 46 const HEADING_TEXT_ATTRIBUTE = "heading_text"; 47 const TYPE_TITLE = "title"; 48 49 const CANONICAL = "heading"; 50 51 const SYNTAX_TYPE = 'baseonly'; 52 const SYNTAX_PTYPE = 'block'; 53 54 /** 55 * The default level if not set 56 * Not level 1 because this is the top level heading 57 * Not level 2 because this is the most used level and we can confound with it 58 */ 59 const DEFAULT_LEVEL = "3"; 60 61 /** 62 * The section generation: 63 * - Dokuwiki section (ie div just after the heading) 64 * - or Combo section (ie section just before the heading) 65 */ 66 public const CONF_SECTION_LAYOUT = 'section_layout'; 67 const CONF_SECTION_LAYOUT_COMBO = "combo"; 68 const CONF_SECTION_LAYOUT_DOKUWIKI = "dokuwiki"; 69 const CONF_SECTION_LAYOUT_VALUES = [self::CONF_SECTION_LAYOUT_COMBO, self::CONF_SECTION_LAYOUT_DOKUWIKI]; 70 const CONF_SECTION_LAYOUT_DEFAULT = self::CONF_SECTION_LAYOUT_COMBO; 71 72 /** 73 * A common function used to handle exit of headings 74 * @param CallStack $callStack 75 * @return array 76 */ 77 public static function handleExit(CallStack $callStack) 78 { 79 /** 80 * Delete the last space if any 81 */ 82 $callStack->moveToEnd(); 83 $previous = $callStack->previous(); 84 if ($previous->getState() == DOKU_LEXER_UNMATCHED) { 85 $previous->setPayload(rtrim($previous->getCapturedContent())); 86 } 87 $callStack->next(); 88 89 /** 90 * Get context data 91 */ 92 $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall(); 93 $openingAttributes = $openingTag->getAttributes(); // for level 94 $context = $openingTag->getContext(); // for sectioning 95 96 return array( 97 PluginUtility::STATE => DOKU_LEXER_EXIT, 98 PluginUtility::ATTRIBUTES => $openingAttributes, 99 PluginUtility::CONTEXT => $context 100 ); 101 } 102 103 public static function processHeadingMetadataH1($level, $text) 104 { 105 /** 106 * Capture the h1 107 */ 108 if ($level == 1) { 109 110 global $ID; 111 p_set_metadata( 112 $ID, 113 array(PageH1::H1_PARSED => trim($text)), 114 false, 115 false // runtime meta 116 ); 117 118 } 119 } 120 121 122 /** 123 * @param $data 124 * @param Doku_Renderer_metadata $renderer 125 */ 126 public static function processHeadingMetadata($data, Doku_Renderer_metadata $renderer) 127 { 128 129 $state = $data[PluginUtility::STATE]; 130 if ($state == DOKU_LEXER_ENTER) { 131 /** 132 * Only outline heading metadata 133 * Not component heading 134 */ 135 $context = $data[PluginUtility::CONTEXT]; 136 if ($context === self::TYPE_OUTLINE) { 137 $callStackArray = $data[PluginUtility::ATTRIBUTES]; 138 $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray); 139 $text = trim($tagAttributes->getValue(syntax_plugin_combo_heading::HEADING_TEXT_ATTRIBUTE)); 140 $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL); 141 self::processHeadingMetadataH1($level, $text); 142 $renderer->header($text, $level, null); 143 } 144 } 145 146 } 147 148 public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer) 149 { 150 $state = $data[PluginUtility::STATE]; 151 if ($state == DOKU_LEXER_ENTER) { 152 /** 153 * Only outline heading metadata 154 * Not component heading 155 */ 156 $context = $data[PluginUtility::CONTEXT]; 157 if ($context == self::TYPE_OUTLINE) { 158 $callStackArray = $data[PluginUtility::ATTRIBUTES]; 159 $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray); 160 $text = $tagAttributes->getValue(syntax_plugin_combo_heading::HEADING_TEXT_ATTRIBUTE); 161 $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL); 162 $renderer->header($text, $level, null); 163 } 164 } 165 } 166 167 168 /** 169 * @param CallStack $callStack 170 * @return string 171 */ 172 public static function getContext($callStack) 173 { 174 175 /** 176 * If the heading is inside a component, 177 * it's a title heading, otherwise it's a outline heading 178 * (Except for {@link syntax_plugin_combo_webcode} that can wrap outline heading) 179 * 180 * When the parent is empty, a section_open (ie another outline heading) 181 * this is a outline 182 */ 183 $parent = $callStack->moveToParent(); 184 if ($parent != false && $parent->getTagName() == syntax_plugin_combo_webcode::TAG) { 185 $parent = $callStack->moveToParent(); 186 } 187 if ($parent != false && $parent->getComponentName() != "section_open") { 188 $headingType = self::TYPE_TITLE; 189 } else { 190 $headingType = self::TYPE_OUTLINE; 191 } 192 193 switch ($headingType) { 194 case syntax_plugin_combo_heading::TYPE_TITLE: 195 196 $context = $parent->getTagName(); 197 break; 198 199 case syntax_plugin_combo_heading::TYPE_OUTLINE: 200 201 $context = syntax_plugin_combo_heading::TYPE_OUTLINE; 202 break; 203 204 default: 205 LogUtility::msg("The heading type ($headingType) is unknown"); 206 $context = ""; 207 break; 208 } 209 return $context; 210 } 211 212 /** 213 * Reduce the end of the input string 214 * to the first opening tag without the ">" 215 * and returns the closing tag 216 * 217 * @param $input 218 * @return array - the heading attributes as a string 219 */ 220 public static function reduceToFirstOpeningTagAndReturnAttributes(&$input) 221 { 222 // the variable that will capture the attribute string 223 $headingStartTagString = ""; 224 // Set to true when the heading tag has completed 225 $endHeadingParsed = false; 226 // The closing character `>` indicator of the start and end tag 227 // true when found 228 $endTagClosingCharacterParsed = false; 229 $startTagClosingCharacterParsed = false; 230 // We start from the edn 231 $position = strlen($input) - 1; 232 while ($position > 0) { 233 $character = $input[$position]; 234 235 if ($character == "<") { 236 if (!$endHeadingParsed) { 237 // We are at the beginning of the ending tag 238 $endHeadingParsed = true; 239 } else { 240 // We have delete all character until the heading start tag 241 // add the last one and exit 242 $headingStartTagString = $character . $headingStartTagString; 243 break; 244 } 245 } 246 247 if ($character == ">") { 248 if (!$endTagClosingCharacterParsed) { 249 // We are at the beginning of the ending tag 250 $endTagClosingCharacterParsed = true; 251 } else { 252 // We have delete all character until the heading start tag 253 $startTagClosingCharacterParsed = true; 254 } 255 } 256 257 if ($startTagClosingCharacterParsed) { 258 $headingStartTagString = $character . $headingStartTagString; 259 } 260 261 262 // position -- 263 $position--; 264 265 } 266 $input = substr($input, 0, $position); 267 268 if (!empty($headingStartTagString)) { 269 return PluginUtility::getTagAttributes($headingStartTagString); 270 } else { 271 LogUtility::msg("The attributes of the heading are empty and this should not be possible"); 272 return []; 273 } 274 275 276 } 277 278 /** 279 * @param string $context 280 * @param TagAttributes $tagAttributes 281 * @param Doku_Renderer_xhtml $renderer 282 * @param integer $pos 283 */ 284 public static function renderOpeningTag($context, $tagAttributes, &$renderer, $pos) 285 { 286 287 /** 288 * Variable 289 */ 290 $type = $tagAttributes->getType(); 291 292 293 /** 294 * Level 295 */ 296 $level = $tagAttributes->getValueAndRemove(syntax_plugin_combo_heading::LEVEL); 297 298 299 /** 300 * Display Heading 301 * https://getbootstrap.com/docs/5.0/content/typography/#display-headings 302 */ 303 if (in_array($type, self::DISPLAY_TYPES)) { 304 305 $displayClass = "display-$level"; 306 307 if (Bootstrap::getBootStrapMajorVersion() == "4") { 308 /** 309 * Make Bootstrap display responsive 310 */ 311 PluginUtility::getSnippetManager()->attachCssInternalStyleSheetForSlot(syntax_plugin_combo_heading::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID); 312 313 if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) { 314 $displayClass = "display-4"; 315 LogUtility::msg("Bootstrap 4 does not support the type ($type). Switch to " . PluginUtility::getDocumentationHyperLink(Bootstrap::CANONICAL, "bootstrap 5") . " if you want to use it. The display type was set to `d4`", LogUtility::LVL_MSG_WARNING, self::CANONICAL); 316 } 317 318 } 319 $tagAttributes->addClassName($displayClass); 320 321 } 322 323 /** 324 * Heading class 325 * https://getbootstrap.com/docs/5.0/content/typography/#headings 326 * Works on 4 and 5 327 */ 328 if (in_array($type, self::HEADING_TYPES)) { 329 $tagAttributes->addClassName($type); 330 } 331 332 /** 333 * Card title Context class 334 * TODO: should move to card 335 */ 336 if (in_array($context, [syntax_plugin_combo_blockquote::TAG, syntax_plugin_combo_card::TAG])) { 337 $tagAttributes->addClassName("card-title"); 338 } 339 340 if ($context == self::TYPE_OUTLINE) { 341 342 /** 343 * Calling the {@link Doku_Renderer_xhtml::header()} 344 * with the captured text to be Dokuwiki Template compatible 345 * It will create the toc and the section editing 346 */ 347 if ($tagAttributes->hasComponentAttribute(self::HEADING_TEXT_ATTRIBUTE)) { 348 $tocText = $tagAttributes->getValueAndRemove(self::HEADING_TEXT_ATTRIBUTE); 349 if (empty($tocText)) { 350 LogUtility::msg("The heading text should be not null on the enter tag"); 351 } 352 if (trim(strtolower($tocText)) === "articles related") { 353 $tagAttributes->addClassName("d-print-none"); 354 } 355 } else { 356 $tocText = "Heading Text Not found"; 357 LogUtility::msg("The heading text attribute was not found for the toc"); 358 } 359 360 361 // note on the position value 362 // this is the exact position because we does not capture any EOL 363 // and therefore the section should start at the first captured character 364 365 $renderer->header($tocText, $level, $pos); 366 $attributes = syntax_plugin_combo_heading::reduceToFirstOpeningTagAndReturnAttributes($renderer->doc); 367 foreach ($attributes as $key => $value) { 368 if ($key === "id" && $tagAttributes->hasAttribute($key)) { 369 // The id was set in the markup, don't overwrite 370 continue; 371 } 372 $tagAttributes->addComponentAttributeValue($key, $value); 373 } 374 375 } 376 377 378 /** 379 * Printing 380 */ 381 $renderer->doc .= $tagAttributes->toHtmlEnterTag("h$level"); 382 383 } 384 385 /** 386 * @param TagAttributes $tagAttributes 387 * @return string 388 */ 389 public static function renderClosingTag(TagAttributes $tagAttributes): string 390 { 391 $level = $tagAttributes->getValueAndRemove(syntax_plugin_combo_heading::LEVEL); 392 if ($level == null) { 393 LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL); 394 } 395 return "</h$level>" . DOKU_LF; 396 } 397 398 399 /** 400 * Syntax Type. 401 * 402 * Needs to return one of the mode types defined in $PARSER_MODES in parser.php 403 * @see DokuWiki_Syntax_Plugin::getType() 404 */ 405 function getType(): string 406 { 407 return self::SYNTAX_TYPE; 408 } 409 410 /** 411 * 412 * How Dokuwiki will add P element 413 * 414 * * 'normal' - The plugin can be used inside paragraphs (inline) 415 * * 'block' - Open paragraphs need to be closed before plugin output - block should not be inside paragraphs 416 * * 'stack' - Special case. Plugin wraps other paragraphs. - Stacks can contain paragraphs 417 * 418 * @see DokuWiki_Syntax_Plugin::getPType() 419 * 420 * This is the equivalent of inline or block for css 421 */ 422 function getPType() 423 { 424 return self::SYNTAX_PTYPE; 425 } 426 427 /** 428 * @return array 429 * Allow which kind of plugin inside 430 * 431 * No one of array('baseonly','container', 'formatting', 'substition', 'protected', 'disabled', 'paragraphs') 432 * because we manage self the content and we call self the parser 433 * 434 * Return an array of one or more of the mode types {@link $PARSER_MODES} in Parser.php 435 */ 436 function getAllowedTypes() 437 { 438 return array('formatting', 'substition', 'protected', 'disabled', 'paragraphs'); 439 } 440 441 /** 442 * 443 * @return int 444 */ 445 function getSort() 446 { 447 return 50; 448 } 449 450 451 function connectTo($mode) 452 { 453 454 /** 455 * Heading tag 456 */ 457 foreach (self::TAGS as $tag) { 458 $this->Lexer->addEntryPattern(PluginUtility::getContainerTagPattern($tag), $mode, PluginUtility::getModeFromTag($this->getPluginComponent())); 459 } 460 461 } 462 463 public function postConnect() 464 { 465 foreach (self::TAGS as $tag) { 466 $this->Lexer->addExitPattern(PluginUtility::getEndTagPattern($tag), PluginUtility::getModeFromTag($this->getPluginComponent())); 467 } 468 } 469 470 471 function handle($match, $state, $pos, Doku_Handler $handler) 472 { 473 474 switch ($state) { 475 476 477 case DOKU_LEXER_ENTER : 478 479 $tagAttributes = TagAttributes::createFromTagMatch($match); 480 481 /** 482 * Level is mandatory (for the closing tag) 483 */ 484 $level = $tagAttributes->getValue(syntax_plugin_combo_heading::LEVEL); 485 if ($level == null) { 486 487 /** 488 * Old title type 489 * from 1 to 4 to set the display heading 490 */ 491 $type = $tagAttributes->getType(); 492 if (is_numeric($type) && $type != 0) { 493 $level = $type; 494 $tagAttributes->setType("d$level"); 495 } 496 /** 497 * Still null, check the type 498 */ 499 if ($level == null) { 500 if (in_array($type, self::ALL_TYPES)) { 501 $level = substr($type, 1); 502 } 503 } 504 /** 505 * Still null, default level 506 */ 507 if ($level == null) { 508 $level = self::DEFAULT_LEVEL; 509 } 510 /** 511 * Set the level 512 */ 513 $tagAttributes->addComponentAttributeValue(self::LEVEL, $level); 514 } 515 516 /** 517 * Context determination 518 */ 519 $callStack = CallStack::createFromHandler($handler); 520 $context = self::getContext($callStack); 521 522 return array( 523 PluginUtility::STATE => $state, 524 PluginUtility::ATTRIBUTES => $tagAttributes->toCallStackArray(), 525 PluginUtility::CONTEXT => $context, 526 PluginUtility::POSITION => $pos 527 ); 528 529 case DOKU_LEXER_UNMATCHED : 530 531 return PluginUtility::handleAndReturnUnmatchedData(self::TAG, $match, $handler); 532 533 case DOKU_LEXER_EXIT : 534 535 $callStack = CallStack::createFromHandler($handler); 536 537 538 /** 539 * Get enter attributes and content 540 */ 541 $callStack->moveToEnd(); 542 $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall(); 543 $context = $openingTag->getContext(); // for sectioning 544 $attributes = $openingTag->getAttributes(); // for the level 545 546 return array( 547 PluginUtility::STATE => $state, 548 PluginUtility::CONTEXT => $context, 549 PluginUtility::ATTRIBUTES => $attributes 550 ); 551 552 553 } 554 return array(); 555 556 } 557 558 /** 559 * Render the output 560 * @param string $format 561 * @param Doku_Renderer $renderer 562 * @param array $data - what the function handle() return'ed 563 * @return boolean - rendered correctly? (however, returned value is not used at the moment) 564 * @see DokuWiki_Syntax_Plugin::render() 565 * 566 * 567 */ 568 function render($format, Doku_Renderer $renderer, $data) 569 { 570 571 if ($format == 'xhtml') { 572 573 /** @var Doku_Renderer_xhtml $renderer */ 574 $state = $data[PluginUtility::STATE]; 575 switch ($state) { 576 577 case DOKU_LEXER_ENTER: 578 $parentTag = $data[PluginUtility::CONTEXT]; 579 $attributes = $data[PluginUtility::ATTRIBUTES]; 580 $pos = $data[PluginUtility::POSITION]; 581 $tagAttributes = TagAttributes::createFromCallStackArray($attributes, syntax_plugin_combo_heading::TAG); 582 self::renderOpeningTag($parentTag, $tagAttributes, $renderer, $pos); 583 break; 584 case DOKU_LEXER_UNMATCHED: 585 $renderer->doc .= PluginUtility::renderUnmatched($data); 586 break; 587 case DOKU_LEXER_EXIT: 588 $attributes = $data[PluginUtility::ATTRIBUTES]; 589 $tagAttributes = TagAttributes::createFromCallStackArray($attributes); 590 $renderer->doc .= self::renderClosingTag($tagAttributes); 591 break; 592 593 } 594 } else if ($format == renderer_plugin_combo_analytics::RENDERER_FORMAT) { 595 596 /** 597 * @var renderer_plugin_combo_analytics $renderer 598 */ 599 syntax_plugin_combo_heading::processMetadataAnalytics($data, $renderer); 600 601 } else if ($format == "metadata") { 602 603 /** 604 * @var Doku_Renderer_metadata $renderer 605 */ 606 syntax_plugin_combo_heading::processHeadingMetadata($data, $renderer); 607 608 } 609 // unsupported $mode 610 return false; 611 } 612 613 614} 615 616