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