1<?php 2 3namespace ComboStrap; 4 5 6use ComboStrap\Meta\Field\PageH1; 7use Doku_Renderer_metadata; 8use Doku_Renderer_xhtml; 9use renderer_plugin_combo_analytics; 10use syntax_plugin_combo_webcode; 11 12class HeadingTag 13{ 14 15 /** 16 * The type of the heading tag 17 */ 18 public const DISPLAY_TYPES = ["d1", "d2", "d3", "d4", "d5", "d6"]; 19 public const HEADING_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6"]; 20 public const SHORT_TYPES = ["1", "2", "3", "4", "5", "6"]; 21 22 /** 23 * The type of the title tag 24 * @deprecated 25 */ 26 public const TITLE_DISPLAY_TYPES = ["0", "1", "2", "3", "4", "5", "6"]; 27 28 29 /** 30 * An heading may be printed 31 * as outline and should be in the toc 32 */ 33 public const TYPE_OUTLINE = "outline"; 34 public const CANONICAL = "heading"; 35 36 public const SYNTAX_TYPE = 'baseonly'; 37 public const SYNTAX_PTYPE = 'block'; 38 /** 39 * The section generation: 40 * - Dokuwiki section (ie div just after the heading) 41 * - or Combo section (ie section just before the heading) 42 */ 43 public const CONF_SECTION_LAYOUT = 'section_layout'; 44 public const CONF_SECTION_LAYOUT_VALUES = [HeadingTag::CONF_SECTION_LAYOUT_COMBO, HeadingTag::CONF_SECTION_LAYOUT_DOKUWIKI]; 45 public const CONF_SECTION_LAYOUT_DEFAULT = HeadingTag::CONF_SECTION_LAYOUT_COMBO; 46 public const LEVEL = 'level'; 47 public const CONF_SECTION_LAYOUT_DOKUWIKI = "dokuwiki"; 48 /** 49 * old tag 50 */ 51 public const TITLE_TAG = "title"; 52 /** 53 * New tag 54 */ 55 public const HEADING_TAG = "heading"; 56 public const LOGICAL_TAG = self::HEADING_TAG; 57 58 59 /** 60 * The default level if not set 61 * Not level 1 because this is the top level heading 62 * Not level 2 because this is the most used level and we can confound with it 63 */ 64 public const DEFAULT_LEVEL_TITLE_CONTEXT = "3"; 65 public const DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID = "display-bs-4"; 66 /** 67 * The attribute that holds only the text of the heading 68 * (used to create the id and the text in the toc) 69 */ 70 public const HEADING_TEXT_ATTRIBUTE = "heading_text"; 71 public const CONF_SECTION_LAYOUT_COMBO = "combo"; 72 73 74 /** 75 * 1 because in test if used without any, this is 76 * the first expected one in a outline 77 */ 78 public const DEFAULT_LEVEL_OUTLINE_CONTEXT = "1"; 79 public const TYPE_TITLE = "title"; 80 public const TAGS = [HeadingTag::HEADING_TAG, HeadingTag::TITLE_TAG]; 81 /** 82 * only available in 5 83 */ 84 public const DISPLAY_TYPES_ONLY_BS_5 = ["d5", "d6"]; 85 86 /** 87 * The label is the text that is generally used 88 * in a TOC but also as default title for the page 89 */ 90 public const PARSED_LABEL = "label"; 91 92 93 /** 94 * A common function used to handle exit of headings 95 * @param \Doku_Handler $handler 96 * @return array 97 */ 98 public static function handleExit(\Doku_Handler $handler): array 99 { 100 101 $callStack = CallStack::createFromHandler($handler); 102 103 /** 104 * Delete the last space if any 105 */ 106 $callStack->moveToEnd(); 107 $previous = $callStack->previous(); 108 if ($previous->getState() == DOKU_LEXER_UNMATCHED) { 109 $previous->setPayload(rtrim($previous->getCapturedContent())); 110 } 111 $callStack->next(); 112 113 /** 114 * Get context data 115 */ 116 $openingTag = $callStack->moveToPreviousCorrespondingOpeningCall(); 117 $openingAttributes = $openingTag->getAttributes(); // for level 118 $context = $openingTag->getContext(); // for sectioning 119 120 return array( 121 PluginUtility::STATE => DOKU_LEXER_EXIT, 122 PluginUtility::ATTRIBUTES => $openingAttributes, 123 PluginUtility::CONTEXT => $context 124 ); 125 } 126 127 /** 128 * @param CallStack $callStack 129 * @return string 130 */ 131 public static function getContext(CallStack $callStack): string 132 { 133 134 /** 135 * If the heading is inside a component, 136 * it's a title heading, otherwise it's a outline heading 137 * 138 * (Except for {@link syntax_plugin_combo_webcode} that can wrap several outline heading) 139 * 140 * When the parent is empty, a section_open (ie another outline heading) 141 * this is a outline 142 */ 143 $parent = $callStack->moveToParent(); 144 if ($parent && $parent->getTagName() === Tag\WebCodeTag::TAG) { 145 $parent = $callStack->moveToParent(); 146 } 147 if ($parent && $parent->getComponentName() !== "section_open") { 148 $headingType = self::TYPE_TITLE; 149 } else { 150 $headingType = self::TYPE_OUTLINE; 151 } 152 153 switch ($headingType) { 154 case HeadingTag::TYPE_TITLE: 155 156 $context = $parent->getTagName(); 157 break; 158 159 case HeadingTag::TYPE_OUTLINE: 160 161 $context = HeadingTag::TYPE_OUTLINE; 162 break; 163 164 default: 165 LogUtility::msg("The heading type ($headingType) is unknown"); 166 $context = ""; 167 break; 168 } 169 return $context; 170 } 171 172 /** 173 * @param $data 174 * @param Doku_Renderer_metadata $renderer 175 */ 176 public static function processHeadingEnterMetadata($data, Doku_Renderer_metadata $renderer) 177 { 178 179 $state = $data[PluginUtility::STATE]; 180 if (!in_array($state, [DOKU_LEXER_ENTER, DOKU_LEXER_SPECIAL])) { 181 return; 182 } 183 /** 184 * Only outline heading metadata 185 * Not component heading 186 */ 187 $context = $data[PluginUtility::CONTEXT]; 188 if ($context === self::TYPE_OUTLINE) { 189 190 $callStackArray = $data[PluginUtility::ATTRIBUTES]; 191 $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray); 192 $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE); 193 if ($text !== null) { 194 $text = trim($text); 195 } 196 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 197 $pos = 0; // mandatory for header but not for metadata, we set 0 to make the code analyser happy 198 $renderer->header($text, $level, $pos); 199 200 if ($level === 1) { 201 $parsedLabel = $tagAttributes->getValue(self::PARSED_LABEL); 202 $renderer->meta[PageH1::H1_PARSED] = $parsedLabel; 203 } 204 205 } 206 207 208 } 209 210 public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer) 211 { 212 213 $state = $data[PluginUtility::STATE]; 214 if ($state !== DOKU_LEXER_ENTER) { 215 return; 216 } 217 /** 218 * Only outline heading metadata 219 * Not component heading 220 */ 221 $context = $data[PluginUtility::CONTEXT] ?? null; 222 if ($context === self::TYPE_OUTLINE) { 223 $callStackArray = $data[PluginUtility::ATTRIBUTES]; 224 $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray); 225 $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE); 226 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 227 $renderer->header($text, $level, 0); 228 } 229 230 } 231 232 /** 233 * @param string $context 234 * @param TagAttributes $tagAttributes 235 * @param Doku_Renderer_xhtml $renderer 236 * @param int|null $pos - null if the call was generated 237 * @return void 238 */ 239 public static function processRenderEnterXhtml(string $context, TagAttributes $tagAttributes, Doku_Renderer_xhtml &$renderer, ?int $pos) 240 { 241 242 /** 243 * All correction that are dependent 244 * on the markup (ie title or heading) 245 * are done in the {@link self::processRenderEnterXhtml()} 246 */ 247 248 /** 249 * Variable 250 */ 251 $type = $tagAttributes->getType(); 252 253 /** 254 * Old syntax deprecated 255 */ 256 if ($type === "0") { 257 if ($context === self::TYPE_OUTLINE) { 258 $type = 'h' . self::DEFAULT_LEVEL_OUTLINE_CONTEXT; 259 } else { 260 $type = 'h' . self::DEFAULT_LEVEL_TITLE_CONTEXT; 261 } 262 } 263 /** 264 * Label is for the TOC 265 */ 266 $tagAttributes->removeAttributeIfPresent(self::PARSED_LABEL); 267 268 269 /** 270 * Level 271 */ 272 $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL); 273 274 /** 275 * Display Heading 276 * https://getbootstrap.com/docs/5.0/content/typography/#display-headings 277 */ 278 if ($context !== self::TYPE_OUTLINE && $type === null) { 279 /** 280 * if not an outline, a display 281 */ 282 $type = "h$level"; 283 } 284 if (in_array($type, self::DISPLAY_TYPES)) { 285 286 $displayClass = "display-$level"; 287 288 if (Bootstrap::getBootStrapMajorVersion() == Bootstrap::BootStrapFourMajorVersion) { 289 /** 290 * Make Bootstrap display responsive 291 */ 292 PluginUtility::getSnippetManager()->attachCssInternalStyleSheet(HeadingTag::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID); 293 294 if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) { 295 $displayClass = "display-4"; 296 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); 297 } 298 299 } 300 $tagAttributes->addClassName($displayClass); 301 } 302 303 /** 304 * Heading class 305 * https://getbootstrap.com/docs/5.0/content/typography/#headings 306 * Works on 4 and 5 307 */ 308 if (in_array($type, self::HEADING_TYPES)) { 309 $tagAttributes->addClassName($type); 310 } 311 312 /** 313 * Card title Context class 314 * TODO: should move to card 315 */ 316 if (in_array($context, [BlockquoteTag::TAG, CardTag::CARD_TAG])) { 317 $tagAttributes->addClassName("card-title"); 318 } 319 320 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 321 322 /** 323 * Add an outline class to be able to style them at once 324 * 325 * The context is by default the parent name or outline. 326 */ 327 $snippetManager = SnippetSystem::getFromContext(); 328 if ($context === self::TYPE_OUTLINE) { 329 330 $tagAttributes->addClassName(Outline::getOutlineHeadingClass()); 331 332 $snippetManager->attachCssInternalStyleSheet(self::TYPE_OUTLINE); 333 334 // numbering 335 try { 336 337 $enable = $executionContext 338 ->getConfig() 339 ->getValue(Outline::CONF_OUTLINE_NUMBERING_ENABLE, Outline::CONF_OUTLINE_NUMBERING_ENABLE_DEFAULT); 340 if ($enable) { 341 $snippet = $snippetManager->attachCssInternalStyleSheet(Outline::OUTLINE_HEADING_NUMBERING); 342 if (!$snippet->hasInlineContent()) { 343 $css = Outline::getCssNumberingRulesFor(Outline::OUTLINE_HEADING_NUMBERING); 344 $snippet->setInlineContent($css); 345 } 346 } 347 } catch (ExceptionBadSyntax $e) { 348 LogUtility::internalError("An error has occurred while trying to add the outline heading numbering stylesheet.", self::CANONICAL, $e); 349 } catch (ExceptionNotEnabled $e) { 350 // ok 351 } 352 353 /** 354 * Anchor on id 355 */ 356 $snippetManager = PluginUtility::getSnippetManager(); 357 try { 358 $snippetManager->attachRemoteJavascriptLibrary( 359 Outline::OUTLINE_ANCHOR, 360 "https://cdn.jsdelivr.net/npm/anchor-js@4.3.0/anchor.min.js", 361 "sha256-LGOWMG4g6/zc0chji4hZP1d8RxR2bPvXMzl/7oPZqjs=" 362 ); 363 } catch (ExceptionBadArgument|ExceptionBadSyntax $e) { 364 // The url has a file name. this error should not happen 365 LogUtility::internalError("Unable to add anchor. Error:{$e->getMessage()}", Outline::OUTLINE_ANCHOR); 366 } 367 $snippetManager->attachJavascriptFromComponentId(Outline::OUTLINE_ANCHOR); 368 369 } 370 $snippetManager->attachCssInternalStyleSheet(HeadingTag::HEADING_TAG); 371 372 /** 373 * Not a HTML attribute 374 */ 375 $tagAttributes->removeComponentAttributeIfPresent(self::HEADING_TEXT_ATTRIBUTE); 376 377 /** 378 * Two headings 1 are shown 379 * 380 * We delete the heading 1 in the instructions 381 * if the template has a content header 382 * 383 * The instructions may not be reprocessed after upgrade for instance 384 * when the installation is done manually 385 * 386 * To avoid to have two headings, we set a display none if this is the case 387 * 388 * Note that this should only apply on the document and not on a partial but yeah 389 * We go that h1 is not used in partials. 390 */ 391 if ($level === 1) { 392 393 $hasMainHeaderElement = TemplateForWebPage::create() 394 ->setRequestedContextPath(ExecutionContext::getActualOrCreateFromEnv()->getContextPath()) 395 ->hasElement(TemplateSlot::MAIN_HEADER_ID); 396 if ($hasMainHeaderElement) { 397 $tagAttributes->addClassName("d-none"); 398 } 399 400 } 401 402 /** 403 * Printing 404 */ 405 $tag = self::getTagFromContext($context, $level); 406 $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag); 407 408 } 409 410 /** 411 * @param TagAttributes $tagAttributes 412 * @param string $context 413 * @return string 414 */ 415 public 416 static function renderClosingTag(TagAttributes $tagAttributes, string $context): string 417 { 418 $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL); 419 if ($level == null) { 420 LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL); 421 } 422 $tag = self::getTagFromContext($context, $level); 423 424 return "</$tag>"; 425 } 426 427 /** 428 * Reduce the end of the input string 429 * to the first opening tag without the ">" 430 * and returns the closing tag 431 * 432 * @param $input 433 * @return array - the heading attributes as a string 434 */ 435 public 436 static function reduceToFirstOpeningTagAndReturnAttributes(&$input) 437 { 438 // the variable that will capture the attribute string 439 $headingStartTagString = ""; 440 // Set to true when the heading tag has completed 441 $endHeadingParsed = false; 442 // The closing character `>` indicator of the start and end tag 443 // true when found 444 $endTagClosingCharacterParsed = false; 445 $startTagClosingCharacterParsed = false; 446 // We start from the edn 447 $position = strlen($input) - 1; 448 while ($position > 0) { 449 $character = $input[$position]; 450 451 if ($character == "<") { 452 if (!$endHeadingParsed) { 453 // We are at the beginning of the ending tag 454 $endHeadingParsed = true; 455 } else { 456 // We have delete all character until the heading start tag 457 // add the last one and exit 458 $headingStartTagString = $character . $headingStartTagString; 459 break; 460 } 461 } 462 463 if ($character == ">") { 464 if (!$endTagClosingCharacterParsed) { 465 // We are at the beginning of the ending tag 466 $endTagClosingCharacterParsed = true; 467 } else { 468 // We have delete all character until the heading start tag 469 $startTagClosingCharacterParsed = true; 470 } 471 } 472 473 if ($startTagClosingCharacterParsed) { 474 $headingStartTagString = $character . $headingStartTagString; 475 } 476 477 478 // position -- 479 $position--; 480 481 } 482 $input = substr($input, 0, $position); 483 484 if (!empty($headingStartTagString)) { 485 return PluginUtility::getTagAttributes($headingStartTagString); 486 } else { 487 LogUtility::msg("The attributes of the heading are empty and this should not be possible"); 488 return []; 489 } 490 491 492 } 493 494 public 495 static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array 496 { 497 /** 498 * Context determination 499 */ 500 $callStack = CallStack::createFromHandler($handler); 501 $context = HeadingTag::getContext($callStack); 502 503 /** 504 * Level is mandatory (for the closing tag) 505 */ 506 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 507 if ($level === null) { 508 509 /** 510 * Old title type 511 * from 1 to 4 to set the display heading 512 */ 513 $type = $tagAttributes->getType(); 514 if (is_numeric($type) && $type != 0) { 515 $level = $type; 516 if ($markupTag === self::TITLE_TAG) { 517 $type = "d$level"; 518 } else { 519 $type = "h$level"; 520 } 521 $tagAttributes->setType($type); 522 } 523 /** 524 * Still null, check the type 525 */ 526 if ($level == null) { 527 if (in_array($type, HeadingTag::getAllTypes())) { 528 $level = substr($type, 1); 529 } 530 } 531 /** 532 * Still null, default level 533 */ 534 if ($level == null) { 535 if ($context === HeadingTag::TYPE_OUTLINE) { 536 $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT; 537 } else { 538 $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT; 539 } 540 } 541 /** 542 * Set the level 543 */ 544 $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level); 545 } 546 return [PluginUtility::CONTEXT => $context]; 547 } 548 549 public 550 static function getAllTypes(): array 551 { 552 return array_merge( 553 self::DISPLAY_TYPES, 554 self::HEADING_TYPES, 555 self::SHORT_TYPES, 556 self::TITLE_DISPLAY_TYPES 557 ); 558 } 559 560 /** 561 * @param string $context 562 * @param int $level 563 * @return string 564 */ 565 private static function getTagFromContext(string $context, int $level): string 566 { 567 if ($context === self::TYPE_OUTLINE) { 568 return "h$level"; 569 } else { 570 return "div"; 571 } 572 } 573 574 575} 576