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 = trim($tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE)); 193 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 194 $pos = 0; // mandatory for header but not for metadata, we set 0 to make the code analyser happy 195 $renderer->header($text, $level, $pos); 196 197 if ($level === 1) { 198 $parsedLabel = $tagAttributes->getValue(self::PARSED_LABEL); 199 $renderer->meta[PageH1::H1_PARSED] = $parsedLabel; 200 } 201 202 } 203 204 205 } 206 207 public static function processMetadataAnalytics(array $data, renderer_plugin_combo_analytics $renderer) 208 { 209 210 $state = $data[PluginUtility::STATE]; 211 if ($state !== DOKU_LEXER_ENTER) { 212 return; 213 } 214 /** 215 * Only outline heading metadata 216 * Not component heading 217 */ 218 $context = $data[PluginUtility::CONTEXT]; 219 if ($context === self::TYPE_OUTLINE) { 220 $callStackArray = $data[PluginUtility::ATTRIBUTES]; 221 $tagAttributes = TagAttributes::createFromCallStackArray($callStackArray); 222 $text = $tagAttributes->getValue(HeadingTag::HEADING_TEXT_ATTRIBUTE); 223 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 224 $renderer->header($text, $level, 0); 225 } 226 227 } 228 229 /** 230 * @param string $context 231 * @param TagAttributes $tagAttributes 232 * @param Doku_Renderer_xhtml $renderer 233 * @param int|null $pos - null if the call was generated 234 * @return void 235 */ 236 public static function processRenderEnterXhtml(string $context, TagAttributes $tagAttributes, Doku_Renderer_xhtml &$renderer, ?int $pos) 237 { 238 239 /** 240 * All correction that are dependent 241 * on the markup (ie title or heading) 242 * are done in the {@link self::processRenderEnterXhtml()} 243 */ 244 245 /** 246 * Variable 247 */ 248 $type = $tagAttributes->getType(); 249 250 /** 251 * Old syntax deprecated 252 */ 253 if ($type === "0") { 254 if ($context === self::TYPE_OUTLINE) { 255 $type = 'h' . self::DEFAULT_LEVEL_OUTLINE_CONTEXT; 256 } else { 257 $type = 'h' . self::DEFAULT_LEVEL_TITLE_CONTEXT; 258 } 259 } 260 /** 261 * Label is for the TOC 262 */ 263 $tagAttributes->removeAttributeIfPresent(self::PARSED_LABEL); 264 265 266 /** 267 * Level 268 */ 269 $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL); 270 271 /** 272 * Display Heading 273 * https://getbootstrap.com/docs/5.0/content/typography/#display-headings 274 */ 275 if ($context !== self::TYPE_OUTLINE && $type === null) { 276 /** 277 * if not an outline, a display 278 */ 279 $type = "h$level"; 280 } 281 if (in_array($type, self::DISPLAY_TYPES)) { 282 283 $displayClass = "display-$level"; 284 285 if (Bootstrap::getBootStrapMajorVersion() == Bootstrap::BootStrapFourMajorVersion) { 286 /** 287 * Make Bootstrap display responsive 288 */ 289 PluginUtility::getSnippetManager()->attachCssInternalStyleSheet(HeadingTag::DISPLAY_BS_4_RESPONSIVE_SNIPPET_ID); 290 291 if (in_array($type, self::DISPLAY_TYPES_ONLY_BS_5)) { 292 $displayClass = "display-4"; 293 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); 294 } 295 296 } 297 $tagAttributes->addClassName($displayClass); 298 } 299 300 /** 301 * Heading class 302 * https://getbootstrap.com/docs/5.0/content/typography/#headings 303 * Works on 4 and 5 304 */ 305 if (in_array($type, self::HEADING_TYPES)) { 306 $tagAttributes->addClassName($type); 307 } 308 309 /** 310 * Card title Context class 311 * TODO: should move to card 312 */ 313 if (in_array($context, [BlockquoteTag::TAG, CardTag::CARD_TAG])) { 314 $tagAttributes->addClassName("card-title"); 315 } 316 317 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 318 319 /** 320 * Add an outline class to be able to style them at once 321 * 322 * The context is by default the parent name or outline. 323 */ 324 $snippetManager = SnippetSystem::getFromContext(); 325 if ($context === self::TYPE_OUTLINE) { 326 327 $tagAttributes->addClassName(Outline::getOutlineHeadingClass()); 328 329 $snippetManager->attachCssInternalStyleSheet(self::TYPE_OUTLINE); 330 331 // numbering 332 try { 333 334 $enable = $executionContext 335 ->getConfig() 336 ->getValue(Outline::CONF_OUTLINE_NUMBERING_ENABLE, Outline::CONF_OUTLINE_NUMBERING_ENABLE_DEFAULT); 337 if ($enable) { 338 $snippet = $snippetManager->attachCssInternalStyleSheet(Outline::OUTLINE_HEADING_NUMBERING); 339 if (!$snippet->hasInlineContent()) { 340 $css = Outline::getCssNumberingRulesFor(Outline::OUTLINE_HEADING_NUMBERING); 341 $snippet->setInlineContent($css); 342 } 343 } 344 } catch (ExceptionBadSyntax $e) { 345 LogUtility::internalError("An error has occurred while trying to add the outline heading numbering stylesheet.", self::CANONICAL, $e); 346 } catch (ExceptionNotEnabled $e) { 347 // ok 348 } 349 350 /** 351 * Anchor on id 352 */ 353 $snippetManager = PluginUtility::getSnippetManager(); 354 try { 355 $snippetManager->attachRemoteJavascriptLibrary( 356 Outline::OUTLINE_ANCHOR, 357 "https://cdn.jsdelivr.net/npm/anchor-js@4.3.0/anchor.min.js", 358 "sha256-LGOWMG4g6/zc0chji4hZP1d8RxR2bPvXMzl/7oPZqjs=" 359 ); 360 } catch (ExceptionBadArgument|ExceptionBadSyntax $e) { 361 // The url has a file name. this error should not happen 362 LogUtility::internalError("Unable to add anchor. Error:{$e->getMessage()}", Outline::OUTLINE_ANCHOR); 363 } 364 $snippetManager->attachJavascriptFromComponentId(Outline::OUTLINE_ANCHOR); 365 366 } 367 $snippetManager->attachCssInternalStyleSheet(HeadingTag::HEADING_TAG); 368 369 /** 370 * Not a HTML attribute 371 */ 372 $tagAttributes->removeComponentAttributeIfPresent(self::HEADING_TEXT_ATTRIBUTE); 373 374 /** 375 * Printing 376 */ 377 $tag = self::getTagFromContext($context, $level); 378 $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag); 379 380 } 381 382 /** 383 * @param TagAttributes $tagAttributes 384 * @param string $context 385 * @return string 386 */ 387 public 388 static function renderClosingTag(TagAttributes $tagAttributes, string $context): string 389 { 390 $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL); 391 if ($level == null) { 392 LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL); 393 } 394 $tag = self::getTagFromContext($context, $level); 395 396 return "</$tag>"; 397 } 398 399 /** 400 * Reduce the end of the input string 401 * to the first opening tag without the ">" 402 * and returns the closing tag 403 * 404 * @param $input 405 * @return array - the heading attributes as a string 406 */ 407 public 408 static function reduceToFirstOpeningTagAndReturnAttributes(&$input) 409 { 410 // the variable that will capture the attribute string 411 $headingStartTagString = ""; 412 // Set to true when the heading tag has completed 413 $endHeadingParsed = false; 414 // The closing character `>` indicator of the start and end tag 415 // true when found 416 $endTagClosingCharacterParsed = false; 417 $startTagClosingCharacterParsed = false; 418 // We start from the edn 419 $position = strlen($input) - 1; 420 while ($position > 0) { 421 $character = $input[$position]; 422 423 if ($character == "<") { 424 if (!$endHeadingParsed) { 425 // We are at the beginning of the ending tag 426 $endHeadingParsed = true; 427 } else { 428 // We have delete all character until the heading start tag 429 // add the last one and exit 430 $headingStartTagString = $character . $headingStartTagString; 431 break; 432 } 433 } 434 435 if ($character == ">") { 436 if (!$endTagClosingCharacterParsed) { 437 // We are at the beginning of the ending tag 438 $endTagClosingCharacterParsed = true; 439 } else { 440 // We have delete all character until the heading start tag 441 $startTagClosingCharacterParsed = true; 442 } 443 } 444 445 if ($startTagClosingCharacterParsed) { 446 $headingStartTagString = $character . $headingStartTagString; 447 } 448 449 450 // position -- 451 $position--; 452 453 } 454 $input = substr($input, 0, $position); 455 456 if (!empty($headingStartTagString)) { 457 return PluginUtility::getTagAttributes($headingStartTagString); 458 } else { 459 LogUtility::msg("The attributes of the heading are empty and this should not be possible"); 460 return []; 461 } 462 463 464 } 465 466 public 467 static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array 468 { 469 /** 470 * Context determination 471 */ 472 $callStack = CallStack::createFromHandler($handler); 473 $context = HeadingTag::getContext($callStack); 474 475 /** 476 * Level is mandatory (for the closing tag) 477 */ 478 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 479 if ($level === null) { 480 481 /** 482 * Old title type 483 * from 1 to 4 to set the display heading 484 */ 485 $type = $tagAttributes->getType(); 486 if (is_numeric($type) && $type != 0) { 487 $level = $type; 488 if ($markupTag === self::TITLE_TAG) { 489 $type = "d$level"; 490 } else { 491 $type = "h$level"; 492 } 493 $tagAttributes->setType($type); 494 } 495 /** 496 * Still null, check the type 497 */ 498 if ($level == null) { 499 if (in_array($type, HeadingTag::getAllTypes())) { 500 $level = substr($type, 1); 501 } 502 } 503 /** 504 * Still null, default level 505 */ 506 if ($level == null) { 507 if ($context === HeadingTag::TYPE_OUTLINE) { 508 $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT; 509 } else { 510 $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT; 511 } 512 } 513 /** 514 * Set the level 515 */ 516 $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level); 517 } 518 return [PluginUtility::CONTEXT => $context]; 519 } 520 521 public 522 static function getAllTypes(): array 523 { 524 return array_merge( 525 self::DISPLAY_TYPES, 526 self::HEADING_TYPES, 527 self::SHORT_TYPES, 528 self::TITLE_DISPLAY_TYPES 529 ); 530 } 531 532 /** 533 * @param string $context 534 * @param int $level 535 * @return string 536 */ 537 private static function getTagFromContext(string $context, int $level): string 538 { 539 if ($context === self::TYPE_OUTLINE) { 540 return "h$level"; 541 } else { 542 return "div"; 543 } 544 } 545 546 547} 548