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