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