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                Lang::PROPERTY_DIR_NAME => $this->getRequestedLangOrDefault()->getDirection()
484            ];
485
486        if (isset($this->model)) {
487            return array_merge($model, $this->model);
488        }
489
490        /**
491         * The width of the layout
492         */
493        $container = $executionConfig->getValue(ContainerTag::DEFAULT_LAYOUT_CONTAINER_CONF, ContainerTag::DEFAULT_LAYOUT_CONTAINER_DEFAULT_VALUE);
494        $containerClass = ContainerTag::getClassName($container);
495        $model["layout-container-class"] = $containerClass;
496
497
498        /**
499         * The rem
500         */
501        try {
502            $model["rem-size"] = $executionConfig->getRemFontSize();
503        } catch (ExceptionNotFound $e) {
504            // ok none
505        }
506
507
508        /**
509         * Body class
510         * {@link tpl_classes} will add the dokuwiki class.
511         * See https://www.dokuwiki.org/devel:templates#dokuwiki_class
512         * dokuwiki__top ID is needed for the "Back to top" utility
513         * used also by some plugins
514         * dokwuiki as class is also needed as it's used by the linkwizard
515         * to locate where to add the node (ie .appendTo('.dokuwiki:first'))
516         */
517        $bodyDokuwikiClass = tpl_classes();
518        try {
519            $bodyTemplateIdentifierClass = StyleAttribute::addComboStrapSuffix("{$this->getTheme()}-{$this->getTemplateName()}");
520        } catch (\Exception $e) {
521            $bodyTemplateIdentifierClass = StyleAttribute::addComboStrapSuffix("template-string");
522        }
523        // position relative is for the toast and messages that are in the corner
524        $model['body-classes'] = "$bodyDokuwikiClass position-relative $bodyTemplateIdentifierClass";
525
526        /**
527         * Data coupled to a page
528         */
529        try {
530
531            $contextPath = $this->getRequestedContextPath();
532            $markupPath = MarkupPath::createPageFromPathObject($contextPath);
533            /**
534             * Meta
535             */
536            $metadata = $markupPath->getMetadataForRendering();
537            $model = array_merge($metadata, $model);
538
539
540            /**
541             * Railbar
542             * You can define the layout type by page
543             * This is not a handelbars helper because it needs some css snippet.
544             */
545            $railBarLayout = $this->getRailbarLayout();
546            try {
547                $model["railbar-html"] = FetcherRailBar::createRailBar()
548                    ->setRequestedLayout($railBarLayout)
549                    ->setRequestedPath($contextPath)
550                    ->getFetchString();
551            } catch (ExceptionBadArgument $e) {
552                LogUtility::error("Error while creating the railbar layout");
553            }
554
555            /**
556             * Css Variables Colors
557             * Added for now in `head-partial.hbs`
558             */
559            try {
560                $primaryColor = $executionConfig->getPrimaryColor();
561                $model[BrandingColors::PRIMARY_COLOR_TEMPLATE_ATTRIBUTE] = $primaryColor->toCssValue();
562                $model[BrandingColors::PRIMARY_COLOR_TEXT_ATTRIBUTE] = ColorSystem::toTextColor($primaryColor);
563                $model[BrandingColors::PRIMARY_COLOR_TEXT_HOVER_ATTRIBUTE] = ColorSystem::toTextHoverColor($primaryColor);
564            } catch (ExceptionNotFound $e) {
565                // not found
566                $model[BrandingColors::PRIMARY_COLOR_TEMPLATE_ATTRIBUTE] = null;
567            }
568            try {
569                $secondaryColor = $executionConfig->getSecondaryColor();
570                $model[BrandingColors::SECONDARY_COLOR_TEMPLATE_ATTRIBUTE] = $secondaryColor->toCssValue();
571            } catch (ExceptionNotFound $e) {
572                // not found
573            }
574
575
576            /**
577             * Main
578             */
579            if (isset($this->mainContent)) {
580                $model["main-content-html"] = $this->mainContent;
581            } else {
582                try {
583                    if (!$markupPath->isSlot()) {
584                        $requestedContextPathForMain = $this->getRequestedContextPath();
585                    } else {
586                        try {
587                            $markupContextPath = SlotSystem::getContextPath();
588                            SlotSystem::sendContextPathMessage($markupContextPath);
589                            $requestedContextPathForMain = $markupContextPath->toWikiPath();
590                        } catch (ExceptionNotFound|ExceptionCast $e) {
591                            $requestedContextPathForMain = $this->getRequestedContextPath();
592                        }
593                    }
594                    $model["main-content-html"] = FetcherMarkup::confRoot()
595                        ->setRequestedMimeToXhtml()
596                        ->setRequestedContextPath($requestedContextPathForMain)
597                        ->setRequestedExecutingPath($this->getRequestedContextPath())
598                        ->build()
599                        ->getFetchString();
600                } catch (ExceptionCompile|ExceptionNotExists|ExceptionNotExists $e) {
601                    LogUtility::error("Error while rendering the page content.", self::CANONICAL, $e);
602                    $model["main-content-html"] = "An error has occured. " . $e->getMessage();
603                }
604            }
605
606            /**
607             * Toc (after main execution please)
608             */
609            $model['toc-class'] = Toc::getClass();
610            $model['toc-html'] = $this->getTocOrDefault()->toXhtml();
611
612            /**
613             * Slots
614             */
615            foreach ($this->getSlots() as $slot) {
616
617                $elementId = $slot->getElementId();
618                try {
619                    $model["$elementId-html"] = $slot->getMarkupFetcher()->getFetchString();
620                } catch (ExceptionNotFound|ExceptionNotExists $e) {
621                    // no slot found
622                } catch (ExceptionCompile $e) {
623                    LogUtility::error("Error while rendering the slot $elementId for the template ($this)", self::CANONICAL, $e);
624                    $model["$elementId-html"] = LogUtility::wrapInRedForHtml("Error: " . $e->getMessage());
625                }
626            }
627
628            /**
629             * Found in {@link tpl_content()}
630             * Used to add html such as {@link \action_plugin_combo_routermessage}
631             * Not sure if this is the right place to add it.
632             */
633            ob_start();
634            global $ACT;
635            \dokuwiki\Extension\Event::createAndTrigger('TPL_ACT_RENDER', $ACT);
636            $tplActRenderOutput = ob_get_clean();
637            if (!empty($tplActRenderOutput)) {
638                $model["main-content-afterbegin-html"] = $tplActRenderOutput;
639                $this->hadMessages = true;
640            }
641
642        } catch (ExceptionNotFound $e) {
643            // no context path
644            if (isset($this->mainContent)) {
645                $model["main-content-html"] = $this->mainContent;
646            }
647        }
648
649
650        /**
651         * Head Html
652         * Snippet, Css and Js from the layout if any
653         *
654         * Note that head tag may be added during rendering and must be then called after rendering and toc
655         * (ie at last then)
656         */
657        $model['head-html'] = $this->getHeadHtml();
658
659        /**
660         * Preloaded Css
661         * (It must come after the head processing as this is where the preloaded script are defined)
662         * (Not really useful but legacy)
663         * We add it just before the end of the body tag
664         */
665        try {
666            $model['preloaded-stylesheet-html'] = $this->getHtmlForPreloadedStyleSheets();
667        } catch (ExceptionNotFound $e) {
668            // no preloaded stylesheet resources
669        }
670
671        /**
672         * Powered by
673         */
674        $model['powered-by'] = self::getPoweredBy();
675
676        /**
677         * Messages
678         * (Should come just before the page creation
679         * due to the $MSG_shown mechanism in {@link html_msgarea()}
680         * We may also get messages in the head
681         */
682        try {
683            $model['messages-html'] = $this->getMessages();
684            /**
685             * Because they must be problem and message with the {@link self::getHeadHtml()}
686             * We process the messages at the end
687             * It means that the needed script needs to be added manually
688             */
689            $model['head-html'] .= Snippet::getOrCreateFromComponentId("toast", Snippet::EXTENSION_JS)->toXhtml();
690        } catch (ExceptionNotFound $e) {
691            // no messages
692        } catch (ExceptionBadState $e) {
693            throw ExceptionRuntimeInternal::withMessageAndError("The toast snippet should have been found", $e);
694        }
695
696        /**
697         * Task runner needs the id
698         */
699        if ($this->requestedEnableTaskRunner && isset($this->requestedContextPath)) {
700            $model['task-runner-html'] = $this->getTaskRunnerImg();
701        }
702
703        return $model;
704    }
705
706
707    private
708    function getRequestedTitleOrDefault(): string
709    {
710
711        if (isset($this->requestedTitle)) {
712            return $this->requestedTitle;
713        }
714
715        try {
716            $path = $this->getRequestedContextPath();
717            $markupPath = MarkupPath::createPageFromPathObject($path);
718            return PageTitle::createForMarkup($markupPath)->getValueOrDefault();
719        } catch (ExceptionNotFound $e) {
720            //
721        }
722        throw new ExceptionBadSyntaxRuntime("A title is mandatory");
723
724
725    }
726
727
728    /**
729     * @throws ExceptionNotFound
730     */
731    private
732    function getTocOrDefault(): Toc
733    {
734
735        if (isset($this->toc)) {
736            /**
737             * The {@link FetcherPageBundler}
738             * bundle pages can create a toc for multiples pages
739             */
740            return $this->toc;
741        }
742
743        $wikiPath = $this->getRequestedContextPath();
744        if (FileSystems::isDirectory($wikiPath)) {
745            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.");
746        }
747        $markup = MarkupPath::createPageFromPathObject($wikiPath);
748        return Toc::createForPage($markup);
749
750    }
751
752    public
753    function setMainContent(string $mainContent): TemplateForWebPage
754    {
755        $this->mainContent = $mainContent;
756        return $this;
757    }
758
759
760    /**
761     * @throws ExceptionBadSyntax
762     */
763    public
764    function renderAsDom(): XmlDocument
765    {
766        return XmlDocument::createHtmlDocFromMarkup($this->render());
767    }
768
769    /**
770     * Add the preloaded CSS resources
771     * at the end
772     * @throws ExceptionNotFound
773     */
774    private
775    function getHtmlForPreloadedStyleSheets(): string
776    {
777
778        // For the preload if any
779        try {
780            $executionContext = ExecutionContext::getActualOrCreateFromEnv();
781            $preloadedCss = $executionContext->getRuntimeObject(self::PRELOAD_TAG);
782        } catch (ExceptionNotFound $e) {
783            throw new ExceptionNotFound("No preloaded resources found");
784        }
785
786        //
787        // Note: Adding this css in an animationFrame
788        // such as https://github.com/jakearchibald/svgomg/blob/master/src/index.html#L183
789        // would be difficult to test
790
791        $class = StyleAttribute::addComboStrapSuffix(self::PRELOAD_TAG);
792        $preloadHtml = "<div class=\"$class\">";
793        foreach ($preloadedCss as $link) {
794            $htmlLink = '<link rel="stylesheet" href="' . $link['href'] . '" ';
795            if (($link['crossorigin'] ?? '') != "") {
796                $htmlLink .= ' crossorigin="' . $link['crossorigin'] . '" ';
797            }
798            if (!empty(($link['class'] ?? null))) {
799                $htmlLink .= ' class="' . $link['class'] . '" ';
800            }
801            // No integrity here
802            $htmlLink .= '>';
803            $preloadHtml .= $htmlLink;
804        }
805        $preloadHtml .= "</div>";
806        return $preloadHtml;
807
808    }
809
810    /**
811     * Variation of {@link html_msgarea()}
812     * @throws ExceptionNotFound
813     */
814    public
815    function getMessages(): string
816    {
817
818        global $MSG;
819
820        if (!isset($MSG)) {
821            throw new ExceptionNotFound("No messages");
822        }
823
824        // deduplicate and auth
825        $uniqueMessages = [];
826        foreach ($MSG as $msg) {
827            if (!info_msg_allowed($msg)) {
828                continue;
829            }
830            $hash = md5($msg['msg']);
831            $uniqueMessages[$hash] = $msg;
832        }
833
834        $messagesByLevel = [];
835        foreach ($uniqueMessages as $message) {
836            $level = $message['lvl'];
837            $messagesByLevel[$level][] = $message;
838        }
839
840        $toasts = "";
841        foreach ($messagesByLevel as $level => $messagesForLevel) {
842            $level = ucfirst($level);
843            switch ($level) {
844                case "Error":
845                    $class = "text-danger";
846                    $levelName = "Error";
847                    break;
848                case "Notify":
849                    $class = "text-warning";
850                    $levelName = "Warning";
851                    break;
852                default:
853                    $levelName = $level;
854                    $class = "text-primary";
855                    break;
856            }
857            $autoHide = "false"; // auto-hidding is really bad ui
858            $toastMessage = "";
859            foreach ($messagesForLevel as $messageForLevel) {
860                $toastMessage .= "<p>{$messageForLevel['msg']}</p>";
861            }
862
863
864            $toasts .= <<<EOF
865<div role="alert" aria-live="assertive" aria-atomic="true" class="toast fade" data-bs-autohide="$autoHide">
866  <div class="toast-header">
867    <strong class="me-auto $class">{$levelName}</strong>
868    <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
869  </div>
870  <div class="toast-body">
871        $toastMessage
872  </div>
873</div>
874EOF;
875        }
876
877        unset($GLOBALS['MSG']);
878
879        if ($toasts === "") {
880            throw new ExceptionNotFound("No messages");
881        }
882
883        $this->hadMessages = true;
884
885        // position fixed to not participate into the grid
886        return <<<EOF
887<div class="toast-container position-fixed mb-3 me-3 bottom-0 end-0" id="toastPlacement" style="z-index:1060">
888$toasts
889</div>
890EOF;
891
892    }
893
894    private
895    function canBeCached(): bool
896    {
897        // no if message
898        return true;
899    }
900
901    /**
902     * Adapted from {@link tpl_indexerWebBug()}
903     * @return string
904     */
905    private
906    function getTaskRunnerImg(): string
907    {
908
909        try {
910            $htmlUrl = UrlEndpoint::createTaskRunnerUrl()
911                ->addQueryParameter(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE, $this->getRequestedContextPath()->getWikiId())
912                ->addQueryParameter(time())
913                ->toString();
914        } catch (ExceptionNotFound $e) {
915            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).");
916        }
917
918        // no more 1x1 px image because of ad blockers
919        return TagAttributes::createEmpty()
920            ->addOutputAttributeValue("id", TemplateForWebPage::TASK_RUNNER_ID)
921            ->addClassName("d-none")
922            ->addOutputAttributeValue('width', 2)
923            ->addOutputAttributeValue('height', 1)
924            ->addOutputAttributeValue('alt', 'Task Runner')
925            ->addOutputAttributeValue('src', $htmlUrl)
926            ->toHtmlEmptyTag("img");
927    }
928
929    private
930    function getRequestedLangOrDefault(): Lang
931    {
932        try {
933            return $this->getRequestedLang();
934        } catch (ExceptionNotFound $e) {
935            return Lang::createFromValue("en");
936        }
937    }
938
939    private
940    function getTheme(): string
941    {
942        return $this->requestedTheme ?? ExecutionContext::getActualOrCreateFromEnv()->getConfig()->getTheme();
943    }
944
945    private
946    function getHeadHtml(): string
947    {
948        $snippetManager = PluginUtility::getSnippetManager();
949
950        if (!$this->isTemplateStringExecutionMode()) {
951
952            /**
953             * Add the layout js and css first
954             */
955
956            try {
957                $cssPath = $this->getCssPath();
958                $content = FileSystems::getContent($cssPath);
959                $snippetManager->attachCssInternalStylesheet(self::CANONICAL, $content);
960            } catch (ExceptionNotFound $e) {
961                // no css found, not a problem
962            }
963            try {
964                $jsPath = $this->getJsPath();
965                $snippetManager->attachInternalJavascriptFromPathForRequest(self::CANONICAL, $jsPath);
966            } catch (ExceptionNotFound $e) {
967                // not found
968            }
969
970
971        }
972
973        /**
974         * Dokuwiki Smiley does not have any height
975         */
976        $snippetManager->attachCssInternalStyleSheet("dokuwiki-smiley");
977
978        /**
979         * Iframe
980         */
981        if ($this->isIframe) {
982            global $EVENT_HANDLER;
983            $EVENT_HANDLER->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'onlyIframeHeadTags');
984        }
985        /**
986         * Start the meta headers
987         */
988        ob_start();
989        try {
990            tpl_metaheaders();
991            $headIcon = $this->getPageIconHeadLinkHtml();
992            return $headIcon . ob_get_contents();
993        } finally {
994            ob_end_clean();
995        }
996
997    }
998
999
1000    public
1001    function setRequestedTemplateName(string $templateName): TemplateForWebPage
1002    {
1003        $this->templateName = $templateName;
1004        return $this;
1005    }
1006
1007    /**
1008     * Add or not the task runner / web bug call
1009     * @param bool $b
1010     * @return TemplateForWebPage
1011     */
1012    public
1013    function setRequestedEnableTaskRunner(bool $b): TemplateForWebPage
1014    {
1015        $this->requestedEnableTaskRunner = $b;
1016        return $this;
1017    }
1018
1019
1020    /**
1021     * @param Lang $requestedLang
1022     * @return TemplateForWebPage
1023     */
1024    public
1025    function setRequestedLang(Lang $requestedLang): TemplateForWebPage
1026    {
1027        $this->requestedLang = $requestedLang;
1028        return $this;
1029    }
1030
1031    /**
1032     * @param string $requestedTitle
1033     * @return TemplateForWebPage
1034     */
1035    public
1036    function setRequestedTitle(string $requestedTitle): TemplateForWebPage
1037    {
1038        $this->requestedTitle = $requestedTitle;
1039        return $this;
1040    }
1041
1042    /**
1043     * Delete the social head tags
1044     * (ie the page should not be indexed)
1045     * This is used for iframe content for instance
1046     * @param bool $isSocial
1047     * @return TemplateForWebPage
1048     */
1049    public
1050    function setIsSocial(bool $isSocial): TemplateForWebPage
1051    {
1052        $this->isSocial = $isSocial;
1053        return $this;
1054    }
1055
1056    public
1057    function setRequestedContextPath(WikiPath $contextPath): TemplateForWebPage
1058    {
1059        $this->requestedContextPath = $contextPath;
1060        return $this;
1061    }
1062
1063    public
1064    function setToc(Toc $toc): TemplateForWebPage
1065    {
1066        $this->toc = $toc;
1067        return $this;
1068    }
1069
1070    /**
1071     * There is two mode of execution, via:
1072     * * a file template (theme)
1073     * * or a string template (string)
1074     *
1075     * @return bool - true if this a string template executions
1076     */
1077    private
1078    function isTemplateStringExecutionMode(): bool
1079    {
1080        return isset($this->templateString);
1081    }
1082
1083    private
1084    function getEngine(): TemplateEngine
1085    {
1086        if ($this->isTemplateStringExecutionMode()) {
1087            return TemplateEngine::createForString();
1088
1089        } else {
1090            $theme = $this->getTheme();
1091            return TemplateEngine::createForTheme($theme);
1092        }
1093    }
1094
1095    private
1096    function getDefinition(): array
1097    {
1098        try {
1099            if (isset($this->templateDefinition)) {
1100                return $this->templateDefinition;
1101            }
1102            $file = $this->getEngine()->searchTemplateByName("{$this->getTemplateName()}.yml");
1103            if (!FileSystems::exists($file)) {
1104                return [];
1105            }
1106            $this->templateDefinition = Yaml::parseFile($file->toAbsoluteId());
1107            return $this->templateDefinition;
1108        } catch (ExceptionNotFound $e) {
1109            // no template directory, not a theme run
1110            return [];
1111        }
1112    }
1113
1114    private
1115    function getRailbarLayout(): string
1116    {
1117        $definition = $this->getDefinition();
1118        if (isset($definition['railbar']['layout'])) {
1119            return $definition['railbar']['layout'];
1120        }
1121        return FetcherRailBar::BOTH_LAYOUT;
1122    }
1123
1124    /**
1125     * Keep the only iframe head tag needed
1126     * @param $event
1127     * @return void
1128     */
1129    public
1130    function onlyIframeHeadTags(&$event)
1131    {
1132
1133        $data = &$event->data;
1134        foreach ($data as $tag => &$heads) {
1135            switch ($tag) {
1136                case "link":
1137                    $deletedRel = ["manifest", "search", "start", "alternate", "canonical"];
1138                    foreach ($heads as $id => $headAttributes) {
1139                        if (isset($headAttributes['rel'])) {
1140                            $rel = $headAttributes['rel'];
1141                            if (in_array($rel, $deletedRel)) {
1142                                unset($heads[$id]);
1143                            }
1144                            if ($rel === "stylesheet") {
1145                                $href = $headAttributes['href'];
1146                                if (strpos($href, "lib/exe/css.php") !== false) {
1147                                    unset($heads[$id]);
1148                                }
1149                            }
1150                        }
1151                    }
1152                    break;
1153                case "meta":
1154                    $deletedMeta = ["og:url", "og:description", "description", "robots"];
1155                    foreach ($heads as $id => $headAttributes) {
1156                        if (isset($headAttributes['name']) || isset($headAttributes['property'])) {
1157                            $rel = $headAttributes['name'] ?? null;
1158                            if ($rel === null) {
1159                                $rel = $headAttributes['property'] ?? null;
1160                            }
1161                            if (in_array($rel, $deletedMeta)) {
1162                                unset($heads[$id]);
1163                            }
1164                        }
1165                    }
1166                    break;
1167                case "script":
1168                    foreach ($heads as $id => $headAttributes) {
1169                        if (isset($headAttributes['src'])) {
1170                            $src = $headAttributes['src'];
1171                            if (strpos($src, "lib/exe/js.php") !== false) {
1172                                unset($heads[$id]);
1173                            }
1174                            if (strpos($src, "lib/exe/jquery.php") !== false) {
1175                                unset($heads[$id]);
1176                            }
1177                        }
1178                    }
1179                    break;
1180            }
1181        }
1182    }
1183
1184}
1185