1<?php 2 3namespace ComboStrap; 4 5 6use ComboStrap\Meta\Field\PageTemplateName; 7use ComboStrap\TagAttribute\StyleAttribute; 8use ComboStrap\Web\UrlEndpoint; 9use ComboStrap\Xml\XmlDocument; 10use ComboStrap\Xml\XmlElement; 11use Symfony\Component\Yaml\Yaml; 12 13/** 14 * A page template is the object 15 * that generates a HTML page 16 * (ie the templating engine) 17 * 18 * It's used by Fetcher that creates pages such 19 * as {@link FetcherPage}, {@link FetcherMarkupWebcode} or {@link FetcherPageBundler} 20 * 21 * Unfortunately, the template is not a runtime parameters 22 * Showing the heading 1 for instance depends on it. 23 */ 24class TemplateForWebPage 25{ 26 27 28 /** 29 * An internal configuration 30 * to tell if the page is social 31 * (ie seo, search engine, friendly) 32 */ 33 const CONF_INTERNAL_IS_SOCIAL = "web-page-is-social"; 34 35 /** 36 * DocType is required by bootstrap and chrome 37 * https://developer.chrome.com/docs/lighthouse/best-practices/doctype/ 38 * https://getbootstrap.com/docs/5.0/getting-started/introduction/#html5-doctype 39 * <!doctype html> 40 * 41 * The eol `\n` is needed for lightouse 42 */ 43 const DOCTYPE = "<!doctype html>\n"; 44 45 private array $templateDefinition; 46 const CANONICAL = "template"; 47 48 49 public const UTF_8_CHARSET_VALUE = "utf-8"; 50 public const VIEWPORT_RESPONSIVE_VALUE = "width=device-width, initial-scale=1"; 51 public const TASK_RUNNER_ID = "task-runner"; 52 public const APPLE_TOUCH_ICON_REL_VALUE = "apple-touch-icon"; 53 54 public const PRELOAD_TAG = "preload"; 55 56 private string $templateName; 57 58 59 private string $requestedTitle; 60 61 62 private bool $requestedEnableTaskRunner = true; 63 private WikiPath $requestedContextPath; 64 private Lang $requestedLang; 65 private Toc $toc; 66 private bool $isSocial; 67 private string $mainContent; 68 private string $templateString; 69 private array $model; 70 private bool $hadMessages = false; 71 private string $requestedTheme; 72 private bool $isIframe = false; 73 private array $slots; 74 75 76 public static function create(): TemplateForWebPage 77 { 78 return new TemplateForWebPage(); 79 } 80 81 public static function config(): TemplateForWebPage 82 { 83 return new TemplateForWebPage(); 84 } 85 86 public static function getPoweredBy(): string 87 { 88 $domain = PluginUtility::$URL_APEX; 89 $version = PluginUtility::$INFO_PLUGIN['version'] . " (" . PluginUtility::$INFO_PLUGIN['date'] . ")"; 90 $poweredBy = "<div class=\"mx-auto\" style=\"width: 300px;text-align: center;margin-bottom: 1rem\">"; 91 $poweredBy .= " <small><i>Powered by <a href=\"$domain\" title=\"ComboStrap " . $version . "\" style=\"color:#495057\">ComboStrap</a></i></small>"; 92 $poweredBy .= '</div>'; 93 return $poweredBy; 94 } 95 96 97 /** 98 * @throws ExceptionNotFound 99 */ 100 public function getHtmlTemplatePath(): LocalPath 101 { 102 return $this->getEngine()->searchTemplateByName($this->templateName . "." . TemplateEngine::EXTENSION_HBS); 103 } 104 105 public function setTemplateString(string $templateString): TemplateForWebPage 106 { 107 $this->templateString = $templateString; 108 return $this; 109 } 110 111 public function setModel(array $model): TemplateForWebPage 112 { 113 $this->model = $model; 114 return $this; 115 } 116 117 /** 118 * @return WikiPath from where the markup slot should be searched 119 * @throws ExceptionNotFound 120 */ 121 public function getRequestedContextPath(): WikiPath 122 { 123 if (!isset($this->requestedContextPath)) { 124 throw new ExceptionNotFound("A requested context path was not found"); 125 } 126 return $this->requestedContextPath; 127 } 128 129 /** 130 * 131 * @return string - the page as html string (not dom because that's not how works dokuwiki) 132 * 133 */ 134 public function render(): string 135 { 136 137 $executionContext = (ExecutionContext::getActualOrCreateFromEnv()) 138 ->setExecutingPageTemplate($this); 139 /** 140 * The deprecated report are just messing up html 141 */ 142 $oldLevel = error_reporting(E_ALL ^ E_DEPRECATED); 143 try { 144 145 146 $pageTemplateEngine = $this->getEngine(); 147 if ($this->isTemplateStringExecutionMode()) { 148 $template = $this->templateString; 149 } else { 150 $pageTemplateEngine = $this->getEngine(); 151 $template = $this->getTemplateName(); 152 if (!$pageTemplateEngine->templateExists($template)) { 153 $defaultTemplate = PageTemplateName::HOLY_TEMPLATE_VALUE; 154 LogUtility::warning("The template ($template) was not found, the default template ($defaultTemplate) was used instead."); 155 $template = $defaultTemplate; 156 $this->setRequestedTemplateName($template); 157 } 158 } 159 160 /** 161 * Get model should came after template validation 162 * as the template definition is named dependent 163 * (Create a builder, nom de dieu) 164 */ 165 $model = $this->getModel(); 166 167 168 return self::DOCTYPE . $pageTemplateEngine->renderWebPage($template, $model); 169 170 171 } finally { 172 error_reporting($oldLevel); 173 $executionContext 174 ->closeExecutingPageTemplate(); 175 } 176 177 } 178 179 /** 180 * @return string[] 181 */ 182 public function getElementIds(): array 183 { 184 $definition = $this->getDefinition(); 185 $elements = $definition['elements'] ?? null; 186 if ($elements == null) { 187 return []; 188 } 189 return $elements; 190 191 } 192 193 194 /** 195 * @throws ExceptionNotFound 196 */ 197 private function getRequestedLang(): Lang 198 { 199 if (!isset($this->requestedLang)) { 200 throw new ExceptionNotFound("No requested lang"); 201 } 202 return $this->requestedLang; 203 } 204 205 206 public function getTemplateName(): string 207 { 208 if (isset($this->templateName)) { 209 return $this->templateName; 210 } 211 try { 212 $requestedPath = $this->getRequestedContextPath(); 213 return PageTemplateName::createFromPage(MarkupPath::createPageFromPathObject($requestedPath)) 214 ->getValueOrDefault(); 215 } catch (ExceptionNotFound $e) { 216 // no requested path 217 } 218 return ExecutionContext::getActualOrCreateFromEnv() 219 ->getConfig() 220 ->getDefaultLayoutName(); 221 } 222 223 224 public function __toString() 225 { 226 return $this->templateName; 227 } 228 229 /** 230 * @throws ExceptionNotFound 231 */ 232 public function getCssPath(): LocalPath 233 { 234 return $this->getEngine()->searchTemplateByName("$this->templateName.css"); 235 } 236 237 /** 238 * @throws ExceptionNotFound 239 */ 240 public function getJsPath(): LocalPath 241 { 242 $jsPath = $this->getEngine()->searchTemplateByName("$this->templateName.js"); 243 if (!FileSystems::exists($jsPath)) { 244 throw new ExceptionNotFound("No js file"); 245 } 246 return $jsPath; 247 } 248 249 public function hasMessages(): bool 250 { 251 return $this->hadMessages; 252 } 253 254 public function setRequestedTheme(string $themeName): TemplateForWebPage 255 { 256 $this->requestedTheme = $themeName; 257 return $this; 258 } 259 260 public function hasElement(string $elementId): bool 261 { 262 return in_array($elementId, $this->getElementIds()); 263 } 264 265 public function isSocial(): bool 266 { 267 if (isset($this->isSocial)) { 268 return $this->isSocial; 269 } 270 try { 271 $path = $this->getRequestedContextPath(); 272 if (!FileSystems::exists($path)) { 273 return false; 274 } 275 $markup = MarkupPath::createPageFromPathObject($path); 276 if ($markup->isSlot()) { 277 // slot are not social 278 return false; 279 } 280 } catch (ExceptionNotFound $e) { 281 // not a path run 282 return false; 283 } 284 if ($this->isIframe) { 285 return false; 286 } 287 return ExecutionContext::getActualOrCreateFromEnv() 288 ->getConfig() 289 ->getBooleanValue(self::CONF_INTERNAL_IS_SOCIAL, true); 290 291 } 292 293 public function setIsIframe(bool $isIframe): TemplateForWebPage 294 { 295 $this->isIframe = $isIframe; 296 return $this; 297 } 298 299 /** 300 * @return TemplateSlot[] 301 */ 302 public function getSlots(): array 303 { 304 if (isset($this->slots)) { 305 return $this->slots; 306 } 307 $this->slots = []; 308 foreach ($this->getElementIds() as $elementId) { 309 if ($elementId === TemplateSlot::MAIN_TOC_ID) { 310 /** 311 * Main toc element is not a slot 312 */ 313 continue; 314 } 315 316 try { 317 $this->slots[] = TemplateSlot::createFromElementId($elementId, $this->getRequestedContextPath()); 318 } catch (ExceptionNotFound $e) { 319 LogUtility::internalError("This template is not for a markup path, it cannot have slots then."); 320 } 321 } 322 return $this->slots; 323 } 324 325 326 /** 327 * Character set 328 * Note: avoid using {@link Html::encode() character entities} in your HTML, 329 * provided their encoding matches that of the document (generally UTF-8) 330 */ 331 private function checkCharSetMeta(XmlElement $head) 332 { 333 $charsetValue = TemplateForWebPage::UTF_8_CHARSET_VALUE; 334 try { 335 $metaCharset = $head->querySelector("meta[charset]"); 336 $charsetActualValue = $metaCharset->getAttribute("charset"); 337 if ($charsetActualValue !== $charsetValue) { 338 LogUtility::warning("The actual charset ($charsetActualValue) should be $charsetValue"); 339 } 340 } catch (ExceptionBadSyntax|ExceptionNotFound $e) { 341 try { 342 $metaCharset = $head->getDocument() 343 ->createElement("meta") 344 ->setAttribute("charset", $charsetValue); 345 $head->appendChild($metaCharset); 346 } catch (\DOMException $e) { 347 throw new ExceptionRuntimeInternal("Bad local name meta, should not occur", self::CANONICAL, 1, $e); 348 } 349 } 350 } 351 352 /** 353 * @param XmlElement $head 354 * @return void 355 * Adapted from {@link TplUtility::renderFaviconMetaLinks()} 356 */ 357 private function getPageIconHeadLinkHtml(): string 358 { 359 $html = $this->getShortcutFavIconHtmlLink(); 360 $html .= $this->getIconHtmlLink(); 361 $html .= $this->getAppleTouchIconHtmlLink(); 362 return $html; 363 } 364 365 /** 366 * Add a favIcon.ico 367 * 368 */ 369 private function getShortcutFavIconHtmlLink(): string 370 { 371 372 $internalFavIcon = WikiPath::createComboResource('images:favicon.ico'); 373 $iconPaths = array( 374 WikiPath::createMediaPathFromId(':favicon.ico'), 375 WikiPath::createMediaPathFromId(':wiki:favicon.ico'), 376 $internalFavIcon 377 ); 378 try { 379 /** 380 * @var WikiPath $icoWikiPath - we give wiki paths, we get wiki path 381 */ 382 $icoWikiPath = FileSystems::getFirstExistingPath($iconPaths); 383 } catch (ExceptionNotFound $e) { 384 LogUtility::internalError("The internal fav icon ($internalFavIcon) should be at minimal found", self::CANONICAL); 385 return ""; 386 } 387 388 return TagAttributes::createEmpty() 389 ->addOutputAttributeValue("rel", "shortcut icon") 390 ->addOutputAttributeValue("href", FetcherRawLocalPath::createFromPath($icoWikiPath)->getFetchUrl()->toAbsoluteUrl()->toString()) 391 ->toHtmlEmptyTag("link"); 392 393 } 394 395 /** 396 * Add Icon Png (16x16 and 32x32) 397 * @return string 398 */ 399 private function getIconHtmlLink(): string 400 { 401 402 $html = ""; 403 $sizeValues = ["32x32", "16x16"]; 404 foreach ($sizeValues as $sizeValue) { 405 406 $internalIcon = WikiPath::createComboResource(":images:favicon-$sizeValue.png"); 407 $iconPaths = array( 408 WikiPath::createMediaPathFromId(":favicon-$sizeValue.png"), 409 WikiPath::createMediaPathFromId(":wiki:favicon-$sizeValue.png"), 410 $internalIcon 411 ); 412 try { 413 /** 414 * @var WikiPath $iconPath - to say to the linter that this is a wiki path 415 */ 416 $iconPath = FileSystems::getFirstExistingPath($iconPaths); 417 } catch (ExceptionNotFound $e) { 418 LogUtility::internalError("The internal icon ($internalIcon) should be at minimal found", self::CANONICAL); 419 continue; 420 } 421 $html .= TagAttributes::createEmpty() 422 ->addOutputAttributeValue("rel", "icon") 423 ->addOutputAttributeValue("sizes", $sizeValue) 424 ->addOutputAttributeValue("type", Mime::PNG) 425 ->addOutputAttributeValue("href", FetcherRawLocalPath::createFromPath($iconPath)->getFetchUrl()->toAbsoluteUrl()->toString()) 426 ->toHtmlEmptyTag("link"); 427 } 428 return $html; 429 } 430 431 /** 432 * Add Apple touch icon 433 * 434 * @return string 435 */ 436 private function getAppleTouchIconHtmlLink(): string 437 { 438 439 $internalIcon = WikiPath::createComboResource(":images:apple-touch-icon.png"); 440 $iconPaths = array( 441 WikiPath::createMediaPathFromId(":apple-touch-icon.png"), 442 WikiPath::createMediaPathFromId(":wiki:apple-touch-icon.png"), 443 $internalIcon 444 ); 445 try { 446 /** 447 * @var WikiPath $iconPath - to say to the linter that this is a wiki path 448 */ 449 $iconPath = FileSystems::getFirstExistingPath($iconPaths); 450 } catch (ExceptionNotFound $e) { 451 LogUtility::internalError("The internal apple icon ($internalIcon) should be at minimal found", self::CANONICAL); 452 return ""; 453 } 454 try { 455 $fetcherLocalPath = FetcherRaster::createImageRasterFetchFromPath($iconPath); 456 $sizesValue = "{$fetcherLocalPath->getIntrinsicWidth()}x{$fetcherLocalPath->getIntrinsicHeight()}"; 457 458 return TagAttributes::createEmpty() 459 ->addOutputAttributeValue("rel", self::APPLE_TOUCH_ICON_REL_VALUE) 460 ->addOutputAttributeValue("sizes", $sizesValue) 461 ->addOutputAttributeValue("type", Mime::PNG) 462 ->addOutputAttributeValue("href", $fetcherLocalPath->getFetchUrl()->toAbsoluteUrl()->toString()) 463 ->toHtmlEmptyTag("link"); 464 } catch (\Exception $e) { 465 LogUtility::internalError("The file ($iconPath) should be found and the local name should be good. Error: {$e->getMessage()}"); 466 return ""; 467 } 468 } 469 470 public 471 function getModel(): array 472 { 473 474 $executionConfig = ExecutionContext::getActualOrCreateFromEnv()->getConfig(); 475 476 /** 477 * Mandatory HTML attributes 478 */ 479 $model = 480 [ 481 PageTitle::PROPERTY_NAME => $this->getRequestedTitleOrDefault(), 482 Lang::PROPERTY_NAME => $this->getRequestedLangOrDefault()->getValueOrDefault(), 483 Lang::PROPERTY_DIR_NAME => $this->getRequestedLangOrDefault()->getDirection() 484 ]; 485 486 if (isset($this->model)) { 487 return array_merge($model, $this->model); 488 } 489 490 /** 491 * The width of the layout 492 */ 493 $container = $executionConfig->getValue(ContainerTag::DEFAULT_LAYOUT_CONTAINER_CONF, ContainerTag::DEFAULT_LAYOUT_CONTAINER_DEFAULT_VALUE); 494 $containerClass = ContainerTag::getClassName($container); 495 $model["layout-container-class"] = $containerClass; 496 497 498 /** 499 * The rem 500 */ 501 try { 502 $model["rem-size"] = $executionConfig->getRemFontSize(); 503 } catch (ExceptionNotFound $e) { 504 // ok none 505 } 506 507 508 /** 509 * Body class 510 * {@link tpl_classes} will add the dokuwiki class. 511 * See https://www.dokuwiki.org/devel:templates#dokuwiki_class 512 * dokuwiki__top ID is needed for the "Back to top" utility 513 * used also by some plugins 514 * dokwuiki as class is also needed as it's used by the linkwizard 515 * to locate where to add the node (ie .appendTo('.dokuwiki:first')) 516 */ 517 $bodyDokuwikiClass = tpl_classes(); 518 try { 519 $bodyTemplateIdentifierClass = StyleAttribute::addComboStrapSuffix("{$this->getTheme()}-{$this->getTemplateName()}"); 520 } catch (\Exception $e) { 521 $bodyTemplateIdentifierClass = StyleAttribute::addComboStrapSuffix("template-string"); 522 } 523 // position relative is for the toast and messages that are in the corner 524 $model['body-classes'] = "$bodyDokuwikiClass position-relative $bodyTemplateIdentifierClass"; 525 526 /** 527 * Data coupled to a page 528 */ 529 try { 530 531 $contextPath = $this->getRequestedContextPath(); 532 $markupPath = MarkupPath::createPageFromPathObject($contextPath); 533 /** 534 * Meta 535 */ 536 $metadata = $markupPath->getMetadataForRendering(); 537 $model = array_merge($metadata, $model); 538 539 540 /** 541 * Railbar 542 * You can define the layout type by page 543 * This is not a handelbars helper because it needs some css snippet. 544 */ 545 $railBarLayout = $this->getRailbarLayout(); 546 try { 547 $model["railbar-html"] = FetcherRailBar::createRailBar() 548 ->setRequestedLayout($railBarLayout) 549 ->setRequestedPath($contextPath) 550 ->getFetchString(); 551 } catch (ExceptionBadArgument $e) { 552 LogUtility::error("Error while creating the railbar layout"); 553 } 554 555 /** 556 * Css Variables Colors 557 * Added for now in `head-partial.hbs` 558 */ 559 try { 560 $primaryColor = $executionConfig->getPrimaryColor(); 561 $model[BrandingColors::PRIMARY_COLOR_TEMPLATE_ATTRIBUTE] = $primaryColor->toCssValue(); 562 $model[BrandingColors::PRIMARY_COLOR_TEXT_ATTRIBUTE] = ColorSystem::toTextColor($primaryColor); 563 $model[BrandingColors::PRIMARY_COLOR_TEXT_HOVER_ATTRIBUTE] = ColorSystem::toTextHoverColor($primaryColor); 564 } catch (ExceptionNotFound $e) { 565 // not found 566 $model[BrandingColors::PRIMARY_COLOR_TEMPLATE_ATTRIBUTE] = null; 567 } 568 try { 569 $secondaryColor = $executionConfig->getSecondaryColor(); 570 $model[BrandingColors::SECONDARY_COLOR_TEMPLATE_ATTRIBUTE] = $secondaryColor->toCssValue(); 571 } catch (ExceptionNotFound $e) { 572 // not found 573 } 574 575 576 /** 577 * Main 578 */ 579 if (isset($this->mainContent)) { 580 $model["main-content-html"] = $this->mainContent; 581 } else { 582 try { 583 if (!$markupPath->isSlot()) { 584 $requestedContextPathForMain = $this->getRequestedContextPath(); 585 } else { 586 try { 587 $markupContextPath = SlotSystem::getContextPath(); 588 SlotSystem::sendContextPathMessage($markupContextPath); 589 $requestedContextPathForMain = $markupContextPath->toWikiPath(); 590 } catch (ExceptionNotFound|ExceptionCast $e) { 591 $requestedContextPathForMain = $this->getRequestedContextPath(); 592 } 593 } 594 $model["main-content-html"] = FetcherMarkup::confRoot() 595 ->setRequestedMimeToXhtml() 596 ->setRequestedContextPath($requestedContextPathForMain) 597 ->setRequestedExecutingPath($this->getRequestedContextPath()) 598 ->build() 599 ->getFetchString(); 600 } catch (ExceptionCompile|ExceptionNotExists|ExceptionNotExists $e) { 601 LogUtility::error("Error while rendering the page content.", self::CANONICAL, $e); 602 $model["main-content-html"] = "An error has occured. " . $e->getMessage(); 603 } 604 } 605 606 /** 607 * Toc (after main execution please) 608 */ 609 $model['toc-class'] = Toc::getClass(); 610 $model['toc-html'] = $this->getTocOrDefault()->toXhtml(); 611 612 /** 613 * Slots 614 */ 615 foreach ($this->getSlots() as $slot) { 616 617 $elementId = $slot->getElementId(); 618 try { 619 $model["$elementId-html"] = $slot->getMarkupFetcher()->getFetchString(); 620 } catch (ExceptionNotFound|ExceptionNotExists $e) { 621 // no slot found 622 } catch (ExceptionCompile $e) { 623 LogUtility::error("Error while rendering the slot $elementId for the template ($this)", self::CANONICAL, $e); 624 $model["$elementId-html"] = LogUtility::wrapInRedForHtml("Error: " . $e->getMessage()); 625 } 626 } 627 628 /** 629 * Found in {@link tpl_content()} 630 * Used to add html such as {@link \action_plugin_combo_routermessage} 631 * Not sure if this is the right place to add it. 632 */ 633 ob_start(); 634 global $ACT; 635 \dokuwiki\Extension\Event::createAndTrigger('TPL_ACT_RENDER', $ACT); 636 $tplActRenderOutput = ob_get_clean(); 637 if (!empty($tplActRenderOutput)) { 638 $model["main-content-afterbegin-html"] = $tplActRenderOutput; 639 $this->hadMessages = true; 640 } 641 642 } catch (ExceptionNotFound $e) { 643 // no context path 644 if (isset($this->mainContent)) { 645 $model["main-content-html"] = $this->mainContent; 646 } 647 } 648 649 650 /** 651 * Head Html 652 * Snippet, Css and Js from the layout if any 653 * 654 * Note that head tag may be added during rendering and must be then called after rendering and toc 655 * (ie at last then) 656 */ 657 $model['head-html'] = $this->getHeadHtml(); 658 659 /** 660 * Preloaded Css 661 * (It must come after the head processing as this is where the preloaded script are defined) 662 * (Not really useful but legacy) 663 * We add it just before the end of the body tag 664 */ 665 try { 666 $model['preloaded-stylesheet-html'] = $this->getHtmlForPreloadedStyleSheets(); 667 } catch (ExceptionNotFound $e) { 668 // no preloaded stylesheet resources 669 } 670 671 /** 672 * Powered by 673 */ 674 $model['powered-by'] = self::getPoweredBy(); 675 676 /** 677 * Messages 678 * (Should come just before the page creation 679 * due to the $MSG_shown mechanism in {@link html_msgarea()} 680 * We may also get messages in the head 681 */ 682 try { 683 $model['messages-html'] = $this->getMessages(); 684 /** 685 * Because they must be problem and message with the {@link self::getHeadHtml()} 686 * We process the messages at the end 687 * It means that the needed script needs to be added manually 688 */ 689 $model['head-html'] .= Snippet::getOrCreateFromComponentId("toast", Snippet::EXTENSION_JS)->toXhtml(); 690 } catch (ExceptionNotFound $e) { 691 // no messages 692 } catch (ExceptionBadState $e) { 693 throw ExceptionRuntimeInternal::withMessageAndError("The toast snippet should have been found", $e); 694 } 695 696 /** 697 * Task runner needs the id 698 */ 699 if ($this->requestedEnableTaskRunner && isset($this->requestedContextPath)) { 700 $model['task-runner-html'] = $this->getTaskRunnerImg(); 701 } 702 703 return $model; 704 } 705 706 707 private 708 function getRequestedTitleOrDefault(): string 709 { 710 711 if (isset($this->requestedTitle)) { 712 return $this->requestedTitle; 713 } 714 715 try { 716 $path = $this->getRequestedContextPath(); 717 $markupPath = MarkupPath::createPageFromPathObject($path); 718 return PageTitle::createForMarkup($markupPath)->getValueOrDefault(); 719 } catch (ExceptionNotFound $e) { 720 // 721 } 722 throw new ExceptionBadSyntaxRuntime("A title is mandatory"); 723 724 725 } 726 727 728 /** 729 * @throws ExceptionNotFound 730 */ 731 private 732 function getTocOrDefault(): Toc 733 { 734 735 if (isset($this->toc)) { 736 /** 737 * The {@link FetcherPageBundler} 738 * bundle pages can create a toc for multiples pages 739 */ 740 return $this->toc; 741 } 742 743 $wikiPath = $this->getRequestedContextPath(); 744 if (FileSystems::isDirectory($wikiPath)) { 745 LogUtility::error("We have a found an inconsistency. The context path is a directory and does have therefore no toc but the template ($this) has a toc."); 746 } 747 $markup = MarkupPath::createPageFromPathObject($wikiPath); 748 return Toc::createForPage($markup); 749 750 } 751 752 public 753 function setMainContent(string $mainContent): TemplateForWebPage 754 { 755 $this->mainContent = $mainContent; 756 return $this; 757 } 758 759 760 /** 761 * @throws ExceptionBadSyntax 762 */ 763 public 764 function renderAsDom(): XmlDocument 765 { 766 return XmlDocument::createHtmlDocFromMarkup($this->render()); 767 } 768 769 /** 770 * Add the preloaded CSS resources 771 * at the end 772 * @throws ExceptionNotFound 773 */ 774 private 775 function getHtmlForPreloadedStyleSheets(): string 776 { 777 778 // For the preload if any 779 try { 780 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 781 $preloadedCss = $executionContext->getRuntimeObject(self::PRELOAD_TAG); 782 } catch (ExceptionNotFound $e) { 783 throw new ExceptionNotFound("No preloaded resources found"); 784 } 785 786 // 787 // Note: Adding this css in an animationFrame 788 // such as https://github.com/jakearchibald/svgomg/blob/master/src/index.html#L183 789 // would be difficult to test 790 791 $class = StyleAttribute::addComboStrapSuffix(self::PRELOAD_TAG); 792 $preloadHtml = "<div class=\"$class\">"; 793 foreach ($preloadedCss as $link) { 794 $htmlLink = '<link rel="stylesheet" href="' . $link['href'] . '" '; 795 if (($link['crossorigin'] ?? '') != "") { 796 $htmlLink .= ' crossorigin="' . $link['crossorigin'] . '" '; 797 } 798 if (!empty(($link['class'] ?? null))) { 799 $htmlLink .= ' class="' . $link['class'] . '" '; 800 } 801 // No integrity here 802 $htmlLink .= '>'; 803 $preloadHtml .= $htmlLink; 804 } 805 $preloadHtml .= "</div>"; 806 return $preloadHtml; 807 808 } 809 810 /** 811 * Variation of {@link html_msgarea()} 812 * @throws ExceptionNotFound 813 */ 814 public 815 function getMessages(): string 816 { 817 818 global $MSG; 819 820 if (!isset($MSG)) { 821 throw new ExceptionNotFound("No messages"); 822 } 823 824 // deduplicate and auth 825 $uniqueMessages = []; 826 foreach ($MSG as $msg) { 827 if (!info_msg_allowed($msg)) { 828 continue; 829 } 830 $hash = md5($msg['msg']); 831 $uniqueMessages[$hash] = $msg; 832 } 833 834 $messagesByLevel = []; 835 foreach ($uniqueMessages as $message) { 836 $level = $message['lvl']; 837 $messagesByLevel[$level][] = $message; 838 } 839 840 $toasts = ""; 841 foreach ($messagesByLevel as $level => $messagesForLevel) { 842 $level = ucfirst($level); 843 switch ($level) { 844 case "Error": 845 $class = "text-danger"; 846 $levelName = "Error"; 847 break; 848 case "Notify": 849 $class = "text-warning"; 850 $levelName = "Warning"; 851 break; 852 default: 853 $levelName = $level; 854 $class = "text-primary"; 855 break; 856 } 857 $autoHide = "false"; // auto-hidding is really bad ui 858 $toastMessage = ""; 859 foreach ($messagesForLevel as $messageForLevel) { 860 $toastMessage .= "<p>{$messageForLevel['msg']}</p>"; 861 } 862 863 864 $toasts .= <<<EOF 865<div role="alert" aria-live="assertive" aria-atomic="true" class="toast fade" data-bs-autohide="$autoHide"> 866 <div class="toast-header"> 867 <strong class="me-auto $class">{$levelName}</strong> 868 <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> 869 </div> 870 <div class="toast-body"> 871 $toastMessage 872 </div> 873</div> 874EOF; 875 } 876 877 unset($GLOBALS['MSG']); 878 879 if ($toasts === "") { 880 throw new ExceptionNotFound("No messages"); 881 } 882 883 $this->hadMessages = true; 884 885 // position fixed to not participate into the grid 886 return <<<EOF 887<div class="toast-container position-fixed mb-3 me-3 bottom-0 end-0" id="toastPlacement" style="z-index:1060"> 888$toasts 889</div> 890EOF; 891 892 } 893 894 private 895 function canBeCached(): bool 896 { 897 // no if message 898 return true; 899 } 900 901 /** 902 * Adapted from {@link tpl_indexerWebBug()} 903 * @return string 904 */ 905 private 906 function getTaskRunnerImg(): string 907 { 908 909 try { 910 $htmlUrl = UrlEndpoint::createTaskRunnerUrl() 911 ->addQueryParameter(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE, $this->getRequestedContextPath()->getWikiId()) 912 ->addQueryParameter(time()) 913 ->toString(); 914 } catch (ExceptionNotFound $e) { 915 throw new ExceptionRuntimeInternal("A request path is mandatory when adding a task runner. Disable it if you don't want one in the layout ($this)."); 916 } 917 918 // no more 1x1 px image because of ad blockers 919 return TagAttributes::createEmpty() 920 ->addOutputAttributeValue("id", TemplateForWebPage::TASK_RUNNER_ID) 921 ->addClassName("d-none") 922 ->addOutputAttributeValue('width', 2) 923 ->addOutputAttributeValue('height', 1) 924 ->addOutputAttributeValue('alt', 'Task Runner') 925 ->addOutputAttributeValue('src', $htmlUrl) 926 ->toHtmlEmptyTag("img"); 927 } 928 929 private 930 function getRequestedLangOrDefault(): Lang 931 { 932 try { 933 return $this->getRequestedLang(); 934 } catch (ExceptionNotFound $e) { 935 return Lang::createFromValue("en"); 936 } 937 } 938 939 private 940 function getTheme(): string 941 { 942 return $this->requestedTheme ?? ExecutionContext::getActualOrCreateFromEnv()->getConfig()->getTheme(); 943 } 944 945 private 946 function getHeadHtml(): string 947 { 948 $snippetManager = PluginUtility::getSnippetManager(); 949 950 if (!$this->isTemplateStringExecutionMode()) { 951 952 /** 953 * Add the layout js and css first 954 */ 955 956 try { 957 $cssPath = $this->getCssPath(); 958 $content = FileSystems::getContent($cssPath); 959 $snippetManager->attachCssInternalStylesheet(self::CANONICAL, $content); 960 } catch (ExceptionNotFound $e) { 961 // no css found, not a problem 962 } 963 try { 964 $jsPath = $this->getJsPath(); 965 $snippetManager->attachInternalJavascriptFromPathForRequest(self::CANONICAL, $jsPath); 966 } catch (ExceptionNotFound $e) { 967 // not found 968 } 969 970 971 } 972 973 /** 974 * Dokuwiki Smiley does not have any height 975 */ 976 $snippetManager->attachCssInternalStyleSheet("dokuwiki-smiley"); 977 978 /** 979 * Iframe 980 */ 981 if ($this->isIframe) { 982 global $EVENT_HANDLER; 983 $EVENT_HANDLER->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'onlyIframeHeadTags'); 984 } 985 /** 986 * Start the meta headers 987 */ 988 ob_start(); 989 try { 990 tpl_metaheaders(); 991 $headIcon = $this->getPageIconHeadLinkHtml(); 992 return $headIcon . ob_get_contents(); 993 } finally { 994 ob_end_clean(); 995 } 996 997 } 998 999 1000 public 1001 function setRequestedTemplateName(string $templateName): TemplateForWebPage 1002 { 1003 $this->templateName = $templateName; 1004 return $this; 1005 } 1006 1007 /** 1008 * Add or not the task runner / web bug call 1009 * @param bool $b 1010 * @return TemplateForWebPage 1011 */ 1012 public 1013 function setRequestedEnableTaskRunner(bool $b): TemplateForWebPage 1014 { 1015 $this->requestedEnableTaskRunner = $b; 1016 return $this; 1017 } 1018 1019 1020 /** 1021 * @param Lang $requestedLang 1022 * @return TemplateForWebPage 1023 */ 1024 public 1025 function setRequestedLang(Lang $requestedLang): TemplateForWebPage 1026 { 1027 $this->requestedLang = $requestedLang; 1028 return $this; 1029 } 1030 1031 /** 1032 * @param string $requestedTitle 1033 * @return TemplateForWebPage 1034 */ 1035 public 1036 function setRequestedTitle(string $requestedTitle): TemplateForWebPage 1037 { 1038 $this->requestedTitle = $requestedTitle; 1039 return $this; 1040 } 1041 1042 /** 1043 * Delete the social head tags 1044 * (ie the page should not be indexed) 1045 * This is used for iframe content for instance 1046 * @param bool $isSocial 1047 * @return TemplateForWebPage 1048 */ 1049 public 1050 function setIsSocial(bool $isSocial): TemplateForWebPage 1051 { 1052 $this->isSocial = $isSocial; 1053 return $this; 1054 } 1055 1056 public 1057 function setRequestedContextPath(WikiPath $contextPath): TemplateForWebPage 1058 { 1059 $this->requestedContextPath = $contextPath; 1060 return $this; 1061 } 1062 1063 public 1064 function setToc(Toc $toc): TemplateForWebPage 1065 { 1066 $this->toc = $toc; 1067 return $this; 1068 } 1069 1070 /** 1071 * There is two mode of execution, via: 1072 * * a file template (theme) 1073 * * or a string template (string) 1074 * 1075 * @return bool - true if this a string template executions 1076 */ 1077 private 1078 function isTemplateStringExecutionMode(): bool 1079 { 1080 return isset($this->templateString); 1081 } 1082 1083 private 1084 function getEngine(): TemplateEngine 1085 { 1086 if ($this->isTemplateStringExecutionMode()) { 1087 return TemplateEngine::createForString(); 1088 1089 } else { 1090 $theme = $this->getTheme(); 1091 return TemplateEngine::createForTheme($theme); 1092 } 1093 } 1094 1095 private 1096 function getDefinition(): array 1097 { 1098 try { 1099 if (isset($this->templateDefinition)) { 1100 return $this->templateDefinition; 1101 } 1102 $file = $this->getEngine()->searchTemplateByName("{$this->getTemplateName()}.yml"); 1103 if (!FileSystems::exists($file)) { 1104 return []; 1105 } 1106 $this->templateDefinition = Yaml::parseFile($file->toAbsoluteId()); 1107 return $this->templateDefinition; 1108 } catch (ExceptionNotFound $e) { 1109 // no template directory, not a theme run 1110 return []; 1111 } 1112 } 1113 1114 private 1115 function getRailbarLayout(): string 1116 { 1117 $definition = $this->getDefinition(); 1118 if (isset($definition['railbar']['layout'])) { 1119 return $definition['railbar']['layout']; 1120 } 1121 return FetcherRailBar::BOTH_LAYOUT; 1122 } 1123 1124 /** 1125 * Keep the only iframe head tag needed 1126 * @param $event 1127 * @return void 1128 */ 1129 public 1130 function onlyIframeHeadTags(&$event) 1131 { 1132 1133 $data = &$event->data; 1134 foreach ($data as $tag => &$heads) { 1135 switch ($tag) { 1136 case "link": 1137 $deletedRel = ["manifest", "search", "start", "alternate", "canonical"]; 1138 foreach ($heads as $id => $headAttributes) { 1139 if (isset($headAttributes['rel'])) { 1140 $rel = $headAttributes['rel']; 1141 if (in_array($rel, $deletedRel)) { 1142 unset($heads[$id]); 1143 } 1144 if ($rel === "stylesheet") { 1145 $href = $headAttributes['href']; 1146 if (strpos($href, "lib/exe/css.php") !== false) { 1147 unset($heads[$id]); 1148 } 1149 } 1150 } 1151 } 1152 break; 1153 case "meta": 1154 $deletedMeta = ["og:url", "og:description", "description", "robots"]; 1155 foreach ($heads as $id => $headAttributes) { 1156 if (isset($headAttributes['name']) || isset($headAttributes['property'])) { 1157 $rel = $headAttributes['name'] ?? null; 1158 if ($rel === null) { 1159 $rel = $headAttributes['property'] ?? null; 1160 } 1161 if (in_array($rel, $deletedMeta)) { 1162 unset($heads[$id]); 1163 } 1164 } 1165 } 1166 break; 1167 case "script": 1168 foreach ($heads as $id => $headAttributes) { 1169 if (isset($headAttributes['src'])) { 1170 $src = $headAttributes['src']; 1171 if (strpos($src, "lib/exe/js.php") !== false) { 1172 unset($heads[$id]); 1173 } 1174 if (strpos($src, "lib/exe/jquery.php") !== false) { 1175 unset($heads[$id]); 1176 } 1177 } 1178 } 1179 break; 1180 } 1181 } 1182 } 1183 1184} 1185