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 * Printing 379 */ 380 $tag = self::getTagFromContext($context, $level); 381 $renderer->doc .= $tagAttributes->toHtmlEnterTag($tag); 382 383 } 384 385 /** 386 * @param TagAttributes $tagAttributes 387 * @param string $context 388 * @return string 389 */ 390 public 391 static function renderClosingTag(TagAttributes $tagAttributes, string $context): string 392 { 393 $level = $tagAttributes->getValueAndRemove(HeadingTag::LEVEL); 394 if ($level == null) { 395 LogUtility::msg("The level is mandatory when closing a heading", self::CANONICAL); 396 } 397 $tag = self::getTagFromContext($context, $level); 398 399 return "</$tag>"; 400 } 401 402 /** 403 * Reduce the end of the input string 404 * to the first opening tag without the ">" 405 * and returns the closing tag 406 * 407 * @param $input 408 * @return array - the heading attributes as a string 409 */ 410 public 411 static function reduceToFirstOpeningTagAndReturnAttributes(&$input) 412 { 413 // the variable that will capture the attribute string 414 $headingStartTagString = ""; 415 // Set to true when the heading tag has completed 416 $endHeadingParsed = false; 417 // The closing character `>` indicator of the start and end tag 418 // true when found 419 $endTagClosingCharacterParsed = false; 420 $startTagClosingCharacterParsed = false; 421 // We start from the edn 422 $position = strlen($input) - 1; 423 while ($position > 0) { 424 $character = $input[$position]; 425 426 if ($character == "<") { 427 if (!$endHeadingParsed) { 428 // We are at the beginning of the ending tag 429 $endHeadingParsed = true; 430 } else { 431 // We have delete all character until the heading start tag 432 // add the last one and exit 433 $headingStartTagString = $character . $headingStartTagString; 434 break; 435 } 436 } 437 438 if ($character == ">") { 439 if (!$endTagClosingCharacterParsed) { 440 // We are at the beginning of the ending tag 441 $endTagClosingCharacterParsed = true; 442 } else { 443 // We have delete all character until the heading start tag 444 $startTagClosingCharacterParsed = true; 445 } 446 } 447 448 if ($startTagClosingCharacterParsed) { 449 $headingStartTagString = $character . $headingStartTagString; 450 } 451 452 453 // position -- 454 $position--; 455 456 } 457 $input = substr($input, 0, $position); 458 459 if (!empty($headingStartTagString)) { 460 return PluginUtility::getTagAttributes($headingStartTagString); 461 } else { 462 LogUtility::msg("The attributes of the heading are empty and this should not be possible"); 463 return []; 464 } 465 466 467 } 468 469 public 470 static function handleEnter(\Doku_Handler $handler, TagAttributes $tagAttributes, string $markupTag): array 471 { 472 /** 473 * Context determination 474 */ 475 $callStack = CallStack::createFromHandler($handler); 476 $context = HeadingTag::getContext($callStack); 477 478 /** 479 * Level is mandatory (for the closing tag) 480 */ 481 $level = $tagAttributes->getValue(HeadingTag::LEVEL); 482 if ($level === null) { 483 484 /** 485 * Old title type 486 * from 1 to 4 to set the display heading 487 */ 488 $type = $tagAttributes->getType(); 489 if (is_numeric($type) && $type != 0) { 490 $level = $type; 491 if ($markupTag === self::TITLE_TAG) { 492 $type = "d$level"; 493 } else { 494 $type = "h$level"; 495 } 496 $tagAttributes->setType($type); 497 } 498 /** 499 * Still null, check the type 500 */ 501 if ($level == null) { 502 if (in_array($type, HeadingTag::getAllTypes())) { 503 $level = substr($type, 1); 504 } 505 } 506 /** 507 * Still null, default level 508 */ 509 if ($level == null) { 510 if ($context === HeadingTag::TYPE_OUTLINE) { 511 $level = HeadingTag::DEFAULT_LEVEL_OUTLINE_CONTEXT; 512 } else { 513 $level = HeadingTag::DEFAULT_LEVEL_TITLE_CONTEXT; 514 } 515 } 516 /** 517 * Set the level 518 */ 519 $tagAttributes->addComponentAttributeValue(HeadingTag::LEVEL, $level); 520 } 521 return [PluginUtility::CONTEXT => $context]; 522 } 523 524 public 525 static function getAllTypes(): array 526 { 527 return array_merge( 528 self::DISPLAY_TYPES, 529 self::HEADING_TYPES, 530 self::SHORT_TYPES, 531 self::TITLE_DISPLAY_TYPES 532 ); 533 } 534 535 /** 536 * @param string $context 537 * @param int $level 538 * @return string 539 */ 540 private static function getTagFromContext(string $context, int $level): string 541 { 542 if ($context === self::TYPE_OUTLINE) { 543 return "h$level"; 544 } else { 545 return "div"; 546 } 547 } 548 549 550} 551