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