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