xref: /plugin/combo/ComboStrap/TemplateForWebPage.php (revision df3434025847a5cfa40797722a55fcfbfea2757a)
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