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