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