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