1<?php
2/**
3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved.
4 *
5 * This source code is licensed under the GPL license found in the
6 * COPYING  file in the root directory of this source tree.
7 *
8 * @license  GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html)
9 * @author   ComboStrap <support@combostrap.com>
10 *
11 */
12
13namespace ComboStrap;
14
15
16use action_plugin_combo_docustom;
17use ComboStrap\TagAttribute\StyleAttribute;
18use ComboStrap\Web\Url;
19use JsonSerializable;
20use splitbrain\slika\Exception;
21
22/**
23 * Class Snippet
24 * @package ComboStrap
25 * A HTML tag:
26 *   * CSS: link for href or style with content
27 *   * Javascript: script
28 *
29 * A component to manage the extra HTML that
30 * comes from components and that should come in the head HTML node
31 *
32 * A snippet identifier is a {@link Snippet::getLocalUrl() local file}
33 *   * if there is content defined, it will be an {@link Snippet::hasInlineContent() inline}
34 *   * if not, it will be the local file with the {@link Snippet::getLocalUrl()}
35 *   * if not found or if the usage of the cdn is required, the {@link Snippet::getRemoteUrl() url} is used
36 *
37 */
38class Snippet implements JsonSerializable
39{
40    /**
41     * The head in css format
42     * We need to add the style node
43     */
44    const EXTENSION_CSS = "css";
45    /**
46     * The head in javascript
47     * We need to wrap it in a script node
48     */
49    const EXTENSION_JS = "js";
50
51    /**
52     * Properties of the JSON array
53     */
54    const JSON_COMPONENT_PROPERTY = "component"; // mandatory
55    const JSON_URI_PROPERTY = "uri"; // internal uri
56    const JSON_URL_PROPERTY = "url"; // external url
57    const JSON_CRITICAL_PROPERTY = "critical";
58    const JSON_ASYNC_PROPERTY = "async";
59    const JSON_CONTENT_PROPERTY = "content";
60    const JSON_INTEGRITY_PROPERTY = "integrity";
61    const JSON_HTML_ATTRIBUTES_PROPERTY = "attributes";
62
63    /**
64     * Not all snippet comes from a component markup
65     * * a menu item may want to add a snippet on a dynamic page
66     * * a snippet may be added just from the head html meta (for anaytics purpose)
67     * * the global css variables
68     * TODO: it should be migrated to the {@link TemplateForWebPage}, ie the request scope is the template scope
69     *    has, these is this object that creates pages
70     */
71    const REQUEST_SCOPE = "request";
72
73    const SLOT_SCOPE = "slot";
74    const ALL_SCOPE = "all";
75    public const COMBO_POPOVER = "combo-popover";
76    const CANONICAL = "snippet";
77    public const STYLE_TAG = "style";
78    public const SCRIPT_TAG = "script";
79    public const LINK_TAG = "link";
80    /**
81     * Use CDN for local stored library
82     */
83    public const CONF_USE_CDN = "useCDN";
84
85    /**
86     * Where to find the file in the combo resources
87     * if any in wiki path syntax
88     */
89    public const LIBRARY_BASE = ':library'; // external script, combo library
90    public const SNIPPET_BASE = ":snippet"; // quick internal snippet
91    public const CONF_USE_CDN_DEFAULT = 1;
92
93    /**
94     * With a raw format, we do nothing
95     * We take it without any questions
96     */
97    const RAW_FORMAT = "raw";
98    /**
99     * With a iife, if the javascript snippet is not critical
100     * It will be wrapped to execute after page load
101     */
102    const IIFE_FORMAT = "iife";
103    /**
104     * Javascript es module
105     */
106    const ES_FORMAT = "es";
107    /**
108     * Umd module
109     */
110    const UMD_FORMAT = "umd";
111    const JSON_FORMAT_PROPERTY = "format";
112    const DEFAULT_FORMAT = self::RAW_FORMAT;
113
114
115    /**
116     * @var bool
117     */
118    private bool $critical;
119
120    /**
121     * @var string the text script / style (may be null if it's an external resources)
122     */
123    private string $inlineContent;
124
125    private Url $remoteUrl;
126
127    private string $integrity;
128
129    private array $htmlAttributes;
130
131
132    /**
133     * @var array - the slots that needs this snippet (as key to get only one snippet by scope)
134     * A special slot exists for {@link Snippet::REQUEST_SCOPE}
135     * where a snippet is for the whole requested page
136     *
137     * It's also used in the cache because not all bars
138     * may render at the same time due to the other been cached.
139     *
140     * There is two scope:
141     *   * a slot - cached along the HTML
142     *   * or  {@link Snippet::REQUEST_SCOPE} - never cached
143     */
144    private $elements;
145
146    /**
147     * @var bool run as soon as possible
148     */
149    private bool $async;
150    private Path $path;
151    private string $componentId;
152
153    /**
154     * @var bool a property to track if a snippet has already been asked in a html output
155     * (ie with a to function such as {@link Snippet::toTagAttributes()} or {@link Snippet::toDokuWikiArray()}
156     * We use it to not delete the state of {@link ExecutionContext} in order to check the created snippet
157     * during an execution
158     *
159     * The positive side effect is that even if the snippet is used in multiple markup for a page,
160     * It will be outputted only once.
161     */
162    private bool $hasHtmlOutputOccurred = false;
163    private string $format = self::DEFAULT_FORMAT;
164
165    /**
166     * @param Path $path - path mandatory because it's the path of fetch and it's the storage format
167     * use {@link Snippet::getOrCreateFromContext()}
168     */
169    private function __construct(Path $path)
170    {
171        $this->path = $path;
172    }
173
174
175    /**
176     * @throws ExceptionBadArgument
177     */
178    public static function createCssSnippetFromComponentId($componentId): Snippet
179    {
180        return Snippet::createSnippetFromComponentId($componentId, self::EXTENSION_CSS);
181    }
182
183    /**
184     * @throws ExceptionBadArgument
185     */
186    public static function createSnippetFromComponentId($componentId, $type): Snippet
187    {
188        $path = self::getInternalPathFromNameAndExtension($componentId, $type);
189        return Snippet::createSnippetFromPath($path)
190            ->setComponentId($componentId);
191    }
192
193
194    /**
195     * The snippet id is the url for external resources (ie external javascript / stylesheet)
196     * otherwise if it's internal, it's the component id and it's type
197     * @param string $componentId - the component id is a short id that you will found in the class (for internal snippet, it helps also resolve the file)
198     * @param string $extension - {@link Snippet::EXTENSION_CSS css} or {@link Snippet::EXTENSION_JS js}
199     * @return Snippet
200     */
201    public static function getOrCreateFromComponentId(string $componentId, string $extension): Snippet
202    {
203
204        $snippetPath = self::getInternalPathFromNameAndExtension($componentId, $extension);
205        return self::getOrCreateFromContext($snippetPath)
206            ->setComponentId($componentId);
207
208    }
209
210
211    /**
212     *
213     *
214     * The order is the order where they were added/created.
215     *
216     * The internal script may be dependent on the external javascript
217     * and vice-versa (for instance, Math-Jax library is dependent
218     * on the config that is an internal inline script)
219     *
220     * @return Snippet[]
221     *
222     */
223    public static function getSnippets(): array
224    {
225        try {
226            return ExecutionContext::getActualOrCreateFromEnv()->getRuntimeObject(self::CANONICAL);
227        } catch (ExceptionNotFound $e) {
228            return [];
229        }
230    }
231
232
233    /**
234     * @param WikiPath $path - a local path of the snippet (if the path does not exist, a remote url should be given)
235     * @return Snippet
236     */
237    public static function createSnippet(Path $path): Snippet
238    {
239        return new Snippet($path);
240    }
241
242
243    /**
244     * @param $snippetId - a logical id
245     * @return string - the class
246     * See also {@link Snippet::getClass()} function
247     */
248    public static function getClassFromComponentId($snippetId): string
249    {
250        return StyleAttribute::addComboStrapSuffix("snippet-" . $snippetId);
251    }
252
253    /**
254     * @throws ExceptionBadArgument
255     */
256    public static function createSnippetFromPath(WikiPath $path): Snippet
257    {
258        return new Snippet($path);
259    }
260
261
262    /**
263     * @param Path $localSnippetPath - the path is the snippet identifier (it's not mandatory that the snippet is locally available
264     * but if it's, it permits to work without any connection by setting the {@link Snippet::CONF_USE_CDN cdn} to off
265     * @return Snippet
266     */
267    public static function getOrCreateFromContext(Path $localSnippetPath): Snippet
268    {
269
270        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
271        try {
272            $snippets = &$executionContext->getRuntimeObject(self::CANONICAL);
273        } catch (ExceptionNotFound $e) {
274            $snippets = [];
275            $executionContext->setRuntimeObject(self::CANONICAL, $snippets);
276        }
277        $snippetGuid = $localSnippetPath->toUriString();
278        $snippet = &$snippets[$snippetGuid];
279        if ($snippet === null) {
280            $snippet = self::createSnippet($localSnippetPath);
281            /**
282             *
283             * The order is the order where they were added/created.
284             *
285             * The internal script may be dependent on the external javascript
286             * and vice-versa (for instance, Math-Jax library is dependent
287             * on the config that is an internal inline script)
288             *
289             */
290            $snippets[$snippetGuid] = $snippet;
291        }
292
293        try {
294            $executingFetcher = $executionContext
295                ->getExecutingMarkupHandler();
296            /**
297             * New way
298             */
299            $executingFetcher->addSnippet($snippet);
300            try {
301                /**
302                 * Old way
303                 * @deprecated
304                 * but still used to store the snippets
305                 */
306                $wikiId = $executingFetcher->getSourcePath()->toWikiPath()->getWikiId();
307                $snippet->addElement($wikiId);
308            } catch (ExceptionCast $e) {
309                // not a wiki path
310            } catch (ExceptionNotFound $e) {
311                /**
312                 * String/dynamic run
313                 * (Example via an {@link \syntax_plugin_combo_iterator})
314                 * The fetcher should have then a parent
315                 */
316                try {
317                    $wikiId = $executionContext->getExecutingParentMarkupHandler()->getSourcePath()->toWikiPath()->getWikiId();
318                    $snippet->addElement($wikiId);
319                } catch (ExceptionCast $e) {
320                    // not a wiki path
321                } catch (ExceptionNotFound $e) {
322                    // no parent found
323                }
324
325            }
326        } catch (ExceptionNotFound $e) {
327            /**
328             * admin page, page scope or theme is not used
329             * This snippets are not due to the markup
330             */
331            try {
332                $executingId = $executionContext->getExecutingWikiId();
333                $snippet->addElement($executingId);
334            } catch (ExceptionNotFound $e) {
335                $snippet->addElement(Snippet::REQUEST_SCOPE);
336            }
337        }
338
339        return $snippet;
340
341    }
342
343    /**
344     * Create a snippet from the ComboDrive
345     * @throws ExceptionBadArgument
346     */
347    public static function createComboSnippet(string $wikiPath): Snippet
348    {
349        $wikiPathObject = WikiPath::createComboResource($wikiPath);
350        return self::createSnippetFromPath($wikiPathObject);
351    }
352
353    /**
354     * @param string $wikiPath - the wiki path should be absolute relative to the library namespace
355     * @return Snippet
356     *
357     * Example: `:bootstrap:4.5.0:bootstrap.min.css`
358     */
359    public static function getOrCreateFromLibraryNamespace(string $wikiPath): Snippet
360    {
361        $wikiPathObject = WikiPath::createComboResource(self::LIBRARY_BASE . $wikiPath);
362        return self::getOrCreateFromContext($wikiPathObject);
363    }
364
365    /**
366     * An utility class to create a snippet from a remote url
367     *
368     * If you want to be able to serve the library locally, you
369     * should create the snippet via the {@link Snippet::getOrCreateFromLibraryNamespace() local path}
370     * and set {@link Snippet::setRemoteUrl() remote url}
371     *
372     * @throws ExceptionBadArgument - if the url does not have a file name
373     */
374    public static function getOrCreateFromRemoteUrl(Url $url): Snippet
375    {
376
377        try {
378            $libraryName = $url->getLastName();
379        } catch (ExceptionNotFound $e) {
380            $messageFormat = "The following url ($url) does not have a file name. To create a snippet from a remote url, the url should have a path where the last is the name of the library file.";
381            throw new ExceptionBadArgument($messageFormat);
382        }
383        /**
384         * The file generally does not exists
385         */
386        $localPath = WikiPath::createComboResource(Snippet::LIBRARY_BASE . ":$libraryName");
387        try {
388            $localPath->getExtension();
389        } catch (ExceptionNotFound $e) {
390            $messageFormat = "The url has a file name ($libraryName) that does not have any extension. To create a snippet from a remote url, the url should have a path where the last is the name of the library file. ";
391            throw new ExceptionBadArgument($messageFormat);
392        }
393        return self::getOrCreateFromContext($localPath)
394            ->setRemoteUrl($url);
395
396
397    }
398
399    /**
400     * @throws ExceptionBadArgument
401     */
402    public static function createJavascriptSnippetFromComponentId(string $componentId): Snippet
403    {
404        return Snippet::createSnippetFromComponentId($componentId, self::EXTENSION_JS);
405    }
406
407
408    /**
409     * @param $bool - if the snippet is critical, it would not be deferred or preloaded
410     * @return Snippet for chaining
411     * All css that are for animation or background for instance
412     * should not be set as critical as they are not needed to paint
413     * exactly the page
414     *
415     * If a snippet is critical, it should not be deferred
416     *
417     * By default:
418     *   * all css are critical (except animation or background stylesheet)
419     *   * all javascript are not critical
420     *
421     * This attribute is passed in the dokuwiki array
422     * The value is stored in the {@link Snippet::getCritical()}
423     */
424    public function setCritical($bool): Snippet
425    {
426        $this->critical = $bool;
427        return $this;
428    }
429
430    /**
431     * If the library does not manipulate the DOM,
432     * it can be ran as soon as possible (ie async)
433     * @param $bool
434     * @return $this
435     */
436    public function setDoesManipulateTheDomOnRun($bool): Snippet
437    {
438        $this->async = !$bool;
439        return $this;
440    }
441
442    /**
443     * @param $inlineContent - Set an inline content for a script or stylesheet
444     * @return Snippet for chaining
445     */
446    public function setInlineContent($inlineContent): Snippet
447    {
448        $this->inlineContent = $inlineContent;
449        return $this;
450    }
451
452    /**
453     * The content that was set via a string (It should be used
454     * for dynamic content, that's why it's called dynamic)
455     * @return string
456     * @throws ExceptionNotFound
457     */
458    public function getInternalDynamicContent(): string
459    {
460        if (!isset($this->inlineContent)) {
461            throw new ExceptionNotFound("No inline content set");
462        }
463        return $this->inlineContent;
464    }
465
466    /**
467     * @return string|null
468     * @throws ExceptionNotFound -  if not found
469     */
470    public function getInternalFileContent(): string
471    {
472        $path = $this->getPath();
473        return FileSystems::getContent($path);
474    }
475
476    public function getPath(): Path
477    {
478        return $this->path;
479    }
480
481    public static function getInternalPathFromNameAndExtension($name, $extension): WikiPath
482    {
483
484        switch ($extension) {
485            case self::EXTENSION_CSS:
486                $extension = "css";
487                return TemplateEngine::createFromContext()
488                    ->getComponentStylePathByName(strtolower($name) . ".$extension");
489            case self::EXTENSION_JS:
490                $extension = "js";
491                $subDirectory = "js";
492                return WikiPath::createComboResource(self::SNIPPET_BASE)
493                    ->resolve($subDirectory)
494                    ->resolve(strtolower($name) . ".$extension");
495            default:
496                $message = "Unknown snippet type ($extension)";
497                throw new ExceptionRuntimeInternal($message);
498        }
499
500    }
501
502    public function hasSlot($slot): bool
503    {
504        if ($this->elements === null) {
505            return false;
506        }
507        return key_exists($slot, $this->elements);
508    }
509
510    public function __toString()
511    {
512        return $this->path->toUriString();
513    }
514
515    public function getCritical(): bool
516    {
517
518        if (isset($this->critical)) {
519            return $this->critical;
520        }
521        try {
522            if ($this->path->getExtension() === self::EXTENSION_CSS) {
523                // All CSS should be loaded first
524                // The CSS animation / background can set this to false
525                return true;
526            }
527        } catch (ExceptionNotFound $e) {
528            // no path extension
529        }
530        return false;
531
532    }
533
534    public function getClass(): string
535    {
536        /**
537         * The class for the snippet is just to be able to identify them
538         *
539         * The `snippet` prefix was added to be sure that the class
540         * name will not conflict with a css class
541         * Example: if you set the class to `combo-list`
542         * and that you use it in a inline `style` tag with
543         * the same class name, the inline `style` tag is not applied
544         *
545         */
546        try {
547            return StyleAttribute::addComboStrapSuffix("snippet-" . $this->getComponentId());
548        } catch (ExceptionNotFound $e) {
549            LogUtility::internalError("A component id was not found for the snippet ($this)", self::CANONICAL);
550            return StyleAttribute::addComboStrapSuffix("snippet");
551        }
552
553    }
554
555
556    /**
557     * @return string - the component name identifier
558     * All snippet with this component id are from the same component
559     * @throws ExceptionNotFound
560     */
561    public function getComponentId(): string
562    {
563        if (isset($this->componentId)) {
564            return $this->componentId;
565        }
566        throw new ExceptionNotFound("No component id was set");
567    }
568
569
570    public function toJsonArray(): array
571    {
572        return $this->jsonSerialize();
573
574    }
575
576    /**
577     * @throws ExceptionCompile
578     */
579    public static function createFromJson($array): Snippet
580    {
581
582        $uri = $array[self::JSON_URI_PROPERTY] ?? null;
583        if ($uri === null) {
584            throw new ExceptionCompile("The snippet uri property was not found in the json array");
585        }
586
587        $wikiPath = FileSystems::createPathFromUri($uri);
588        $snippet = Snippet::getOrCreateFromContext($wikiPath);
589
590        $componentName = $array[self::JSON_COMPONENT_PROPERTY] ?? null;
591        if ($componentName !== null) {
592            $snippet->setComponentId($componentName);
593        }
594
595        $critical = $array[self::JSON_CRITICAL_PROPERTY] ?? null;
596        if ($critical !== null) {
597            $snippet->setCritical($critical);
598        }
599
600        $async = $array[self::JSON_ASYNC_PROPERTY] ?? null;
601        if ($async !== null) {
602            $snippet->setDoesManipulateTheDomOnRun($async);
603        }
604
605        $format = $array[self::JSON_FORMAT_PROPERTY] ?? null;
606        if ($format !== null) {
607            $snippet->setFormat($format);
608        }
609
610        $content = $array[self::JSON_CONTENT_PROPERTY] ?? null;
611        if ($content !== null) {
612            $snippet->setInlineContent($content);
613        }
614
615        $attributes = $array[self::JSON_HTML_ATTRIBUTES_PROPERTY] ?? null;
616        if ($attributes !== null) {
617            foreach ($attributes as $name => $value) {
618                $snippet->addHtmlAttribute($name, $value);
619            }
620        }
621
622        $integrity = $array[self::JSON_INTEGRITY_PROPERTY] ?? null;
623        if ($integrity !== null) {
624            $snippet->setIntegrity($integrity);
625        }
626
627        $remoteUrl = $array[self::JSON_URL_PROPERTY] ?? null;
628        if ($remoteUrl !== null) {
629            $snippet->setRemoteUrl(Url::createFromString($remoteUrl));
630        }
631
632        return $snippet;
633
634    }
635
636    public function getExtension()
637    {
638        return $this->path->getExtension();
639    }
640
641    public function setIntegrity(?string $integrity): Snippet
642    {
643        if ($integrity === null) {
644            return $this;
645        }
646        $this->integrity = $integrity;
647        return $this;
648    }
649
650    public function addHtmlAttribute(string $name, string $value): Snippet
651    {
652        $this->htmlAttributes[$name] = $value;
653        return $this;
654    }
655
656    public function addElement(string $element): Snippet
657    {
658        $this->elements[$element] = 1;
659        return $this;
660    }
661
662    public function useLocalUrl(): bool
663    {
664
665        /**
666         * use cdn is on and there is a remote url
667         */
668        $useCdn = ExecutionContext::getActualOrCreateFromEnv()->getConfValue(self::CONF_USE_CDN, self::CONF_USE_CDN_DEFAULT) === 1;
669        if ($useCdn && isset($this->remoteUrl)) {
670            return false;
671        }
672
673        /**
674         * use cdn is off and there is a file
675         */
676        $fileExists = FileSystems::exists($this->path);
677        if ($fileExists) {
678            return true;
679        }
680
681        /**
682         * Use cdn is off and there is a remote url
683         */
684        if (isset($this->remoteUrl)) {
685            return false;
686        }
687
688        /**
689         *
690         * This is a inline script (no local file then)
691         *
692         * We default to the local url that will return an error
693         * when fetched
694         */
695        if (!$this->shouldBeInlined()) {
696            LogUtility::internalError("The snippet ($this) is not a inline script, it has a path ($this->path) that does not exists and does not have any external url.");
697        }
698        return false;
699
700    }
701
702    /**
703     *
704     */
705    public function getLocalUrl(): Url
706    {
707        try {
708            $path = WikiPath::createFromPathObject($this->path);
709            return FetcherRawLocalPath::createFromPath($path)->getFetchUrl();
710        } catch (ExceptionBadArgument $e) {
711            throw new ExceptionRuntimeInternal("The local url should ne asked. use (hasLocalUrl) before calling this function", self::CANONICAL, $e);
712        }
713
714    }
715
716
717    /**
718     * @throws ExceptionNotFound
719     */
720    public function getRemoteUrl(): Url
721    {
722        if (!isset($this->remoteUrl)) {
723            throw new ExceptionNotFound("No remote url found");
724        }
725        return $this->remoteUrl;
726    }
727
728    /**
729     * @throws ExceptionNotFound
730     */
731    public function getIntegrity(): ?string
732    {
733        if (!isset($this->integrity)) {
734            throw new ExceptionNotFound("No integrity");
735        }
736        return $this->integrity;
737    }
738
739    public function getHtmlAttributes(): array
740    {
741        if (!isset($this->htmlAttributes)) {
742            return [];
743        }
744        return $this->htmlAttributes;
745    }
746
747
748    /**
749     * @throws ExceptionNotFound
750     */
751    public function getInternalInlineAndFileContent(): string
752    {
753        $totalContent = null;
754        try {
755            $totalContent = $this->getInternalFileContent();
756        } catch (ExceptionNotFound $e) {
757            // no
758        }
759
760        try {
761            $totalContent .= $this->getInternalDynamicContent();
762        } catch (ExceptionNotFound $e) {
763            // no
764        }
765        if ($totalContent === null) {
766            throw new ExceptionNotFound("No content");
767        }
768        return $totalContent;
769
770    }
771
772
773    /**
774     */
775    public function jsonSerialize(): array
776    {
777
778        $dataToSerialize = [
779            self::JSON_URI_PROPERTY => $this->getPath()->toUriString(),
780        ];
781
782        try {
783            $dataToSerialize[self::JSON_COMPONENT_PROPERTY] = $this->getComponentId();
784        } catch (ExceptionNotFound $e) {
785            LogUtility::internalError("The component id was not set for the snippet ($this)");
786        }
787
788        if (isset($this->remoteUrl)) {
789            $dataToSerialize[self::JSON_URL_PROPERTY] = $this->remoteUrl->toString();
790        }
791        if (isset($this->integrity)) {
792            $dataToSerialize[self::JSON_INTEGRITY_PROPERTY] = $this->integrity;
793        }
794        if (isset($this->critical)) {
795            $dataToSerialize[self::JSON_CRITICAL_PROPERTY] = $this->critical;
796        }
797        if (isset($this->async)) {
798            $dataToSerialize[self::JSON_ASYNC_PROPERTY] = $this->async;
799        }
800        if (isset($this->inlineContent)) {
801            $dataToSerialize[self::JSON_CONTENT_PROPERTY] = $this->inlineContent;
802        }
803        if ($this->format !== self::DEFAULT_FORMAT) {
804            $dataToSerialize[self::JSON_FORMAT_PROPERTY] = $this->format;
805        }
806        if (isset($this->htmlAttributes)) {
807            $dataToSerialize[self::JSON_HTML_ATTRIBUTES_PROPERTY] = $this->htmlAttributes;
808        }
809        return $dataToSerialize;
810    }
811
812
813    public function hasInlineContent(): bool
814    {
815        return isset($this->inlineContent);
816    }
817
818    private
819    function getMaxInlineSize()
820    {
821        return SiteConfig::getConfValue(SiteConfig::HTML_MAX_KB_SIZE_FOR_INLINE_ELEMENT, 2) * 1024;
822    }
823
824    /**
825     * Returns if the internal snippet should be incorporated
826     * in the page or not
827     *
828     * Requiring a lot of small javascript file adds a penalty to page load
829     *
830     * @return bool
831     */
832    public function shouldBeInlined(): bool
833    {
834        /**
835         * If there is inline content, true
836         */
837        if ($this->hasInlineContent()) {
838            return true;
839        }
840
841        /**
842         * If the file does not exist
843         * and that there is a remote url
844         */
845        $internalPath = $this->getPath();
846        if (!FileSystems::exists($internalPath)) {
847            try {
848                $this->getRemoteUrl();
849                return false;
850            } catch (ExceptionNotFound $e) {
851                // no remote url, no file, no inline: error
852                LogUtility::internalError("The snippet ($this) does not have content defined (the path does not exist, no inline content and no remote url)", self::CANONICAL);
853                return true;
854            }
855        }
856
857        /**
858         * The file exists
859         * If we can't serve it locally, it should be inlined
860         */
861        if (!$this->hasLocalUrl()) {
862            return true;
863        }
864
865        /**
866         * File exists and can be served
867         */
868
869        /**
870         * Local Javascript Library ?
871         */
872        if ($this->getExtension() === Snippet::EXTENSION_JS) {
873
874            try {
875                $lastName = $internalPath->getLastName();
876            } catch (ExceptionNotFound $e) {
877                LogUtility::internalError("Every snippet should have a last name");
878                return false;
879            }
880
881            /**
882             * If this is a local library don't inline
883             * Why ?
884             * The local combo.min.js library depends on bootstrap.min.js
885             * If we inject it, we get `bootstrap` does not exist.
886             * Other solution, to resolve it, we could:
887             * * inline all javascript
888             * * start a local server and serve the local library
889             * * publish the local library (not really realistic if we test but yeah)
890             */
891            $libraryExtension = ".min.js";
892            $isLibrary = substr($lastName, -strlen($libraryExtension)) === $libraryExtension;
893            if (!$this->hasRemoteUrl() && !$isLibrary) {
894                $alwaysInline = ExecutionContext::getActualOrCreateFromEnv()
895                    ->getConfig()
896                    ->isLocalJavascriptAlwaysInlined();
897                if ($alwaysInline) {
898                    return true;
899                }
900            }
901        }
902
903        /**
904         * The file exists (inline if small size)
905         */
906        if (FileSystems::getSize($internalPath) > $this->getMaxInlineSize()) {
907            return false;
908        }
909
910        return true;
911
912    }
913
914    /**
915     * @return array
916     * @throws ExceptionBadArgument
917     * @throws ExceptionBadState - an error where for instance an inline script does not have any content
918     * @throws ExceptionCast
919     * @throws ExceptionNotFound - an error where the source was not found
920     */
921    public function toDokuWikiArray(): array
922    {
923        $tagAttributes = $this->toTagAttributes();
924        $array = $tagAttributes->toCallStackArray();
925        unset($array[TagAttributes::GENERATED_ID_KEY]);
926        return $array;
927    }
928
929
930    /**
931     * The HTML tag
932     */
933    public function getHtmlTag(): string
934    {
935        $extension = $this->getExtension();
936        switch ($extension) {
937            case Snippet::EXTENSION_JS:
938                return self::SCRIPT_TAG;
939            case Snippet::EXTENSION_CSS:
940                if ($this->shouldBeInlined()) {
941                    return Snippet::STYLE_TAG;
942                } else {
943                    return Snippet::LINK_TAG;
944                }
945            default:
946                // it should not happen as the devs are the creator of snippet (not the user)
947                LogUtility::internalError("The extension ($extension) is unknown", self::CANONICAL);
948                return "";
949        }
950    }
951
952    public function setComponentId(string $componentId): Snippet
953    {
954        $this->componentId = $componentId;
955        return $this;
956    }
957
958    public function setRemoteUrl(Url $url): Snippet
959    {
960        $this->remoteUrl = $url;
961        return $this;
962    }
963
964
965    public function useRemoteUrl(): bool
966    {
967        return !$this->useLocalUrl();
968    }
969
970    /**
971     * @return TagAttributes
972     * @throws ExceptionBadArgument
973     * @throws ExceptionBadState - if no content was found
974     * @throws ExceptionCast
975     * @throws ExceptionNotFound - if the file was not found
976     */
977    public function toTagAttributes(): TagAttributes
978    {
979
980        if ($this->hasHtmlOutputOccurred) {
981            $message = "The snippet ($this) has already been asked. It may have been added twice to the HTML page";
982            if (PluginUtility::isTest()) {
983                $message = "Error: you may be running two pages fetch in the same execution context. $message";
984            }
985            LogUtility::internalError($message);
986        }
987        $this->hasHtmlOutputOccurred = true;
988
989        $tagAttributes = TagAttributes::createFromCallStackArray($this->getHtmlAttributes())
990            ->addClassName($this->getClass());
991        $extension = $this->getExtension();
992        switch ($extension) {
993            case Snippet::EXTENSION_JS:
994
995                if ($this->shouldBeInlined()) {
996
997                    try {
998                        $tagAttributes->setInnerText($this->getInnerHtml());
999                        return $tagAttributes;
1000                    } catch (ExceptionNotFound $e) {
1001                        throw new ExceptionBadState("The internal js snippet ($this) has no content. Skipped", self::CANONICAL);
1002                    }
1003
1004                } else {
1005
1006                    if ($this->useRemoteUrl()) {
1007                        $fetchUrl = $this->getRemoteUrl();
1008                    } else {
1009                        $fetchUrl = $this->getLocalUrl();
1010                    }
1011
1012                    /**
1013                     * Dokuwiki encodes the URL in HTML format
1014                     */
1015                    $tagAttributes
1016                        ->addOutputAttributeValue("src", $fetchUrl->toString())
1017                        ->addOutputAttributeValue("crossorigin", "anonymous");
1018                    try {
1019                        $integrity = $this->getIntegrity();
1020                        $tagAttributes->addOutputAttributeValue("integrity", $integrity);
1021                    } catch (ExceptionNotFound $e) {
1022                        // ok
1023                    }
1024                    $critical = $this->getCritical();
1025                    if (!$critical) {
1026                        $tagAttributes->addBooleanOutputAttributeValue("defer");
1027                        // not async: it will run as soon as possible
1028                        // the dom main not be loaded completely, the script may miss HTML dom element
1029                    }
1030                    return $tagAttributes;
1031
1032                }
1033
1034            case Snippet::EXTENSION_CSS:
1035
1036                if ($this->shouldBeInlined()) {
1037
1038                    try {
1039                        $tagAttributes->setInnerText($this->getInnerHtml());
1040                        return $tagAttributes;
1041                    } catch (ExceptionNotFound $e) {
1042                        throw new ExceptionNotFound("The internal css snippet ($this) has no content.", self::CANONICAL);
1043                    }
1044
1045                } else {
1046
1047                    if ($this->useRemoteUrl()) {
1048                        $fetchUrl = $this->getRemoteUrl();
1049                    } else {
1050                        $fetchUrl = $this->getLocalUrl();
1051                    }
1052
1053                    /**
1054                     * Dokuwiki transforms/encode the href in HTML
1055                     */
1056                    $tagAttributes
1057                        ->addOutputAttributeValue("href", $fetchUrl->toString())
1058                        ->addOutputAttributeValue("crossorigin", "anonymous");
1059
1060                    try {
1061                        $integrity = $this->getIntegrity();
1062                        $tagAttributes->addOutputAttributeValue("integrity", $integrity);
1063                    } catch (ExceptionNotFound $e) {
1064                        // ok
1065                    }
1066
1067                    $critical = $this->getCritical();
1068                    if (!$critical && action_plugin_combo_docustom::isThemeSystemEnabled()) {
1069                        $tagAttributes
1070                            ->addOutputAttributeValue("rel", "preload")
1071                            ->addOutputAttributeValue('as', self::STYLE_TAG);
1072                    } else {
1073                        $tagAttributes->addOutputAttributeValue("rel", "stylesheet");
1074                    }
1075
1076                    return $tagAttributes;
1077
1078                }
1079
1080
1081            default:
1082                throw new ExceptionBadState("The extension ($extension) is unknown", self::CANONICAL);
1083        }
1084
1085    }
1086
1087    /**
1088     * @return bool - yes if the function {@link Snippet::toTagAttributes()}
1089     * or {@link Snippet::toDokuWikiArray()} has been called
1090     * to prevent having the snippet two times (one in the head and one in the body)
1091     */
1092    public function hasHtmlOutputAlreadyOccurred(): bool
1093    {
1094
1095        return $this->hasHtmlOutputOccurred;
1096
1097    }
1098
1099    private function hasRemoteUrl(): bool
1100    {
1101        try {
1102            $this->getRemoteUrl();
1103            return true;
1104        } catch (ExceptionNotFound $e) {
1105            return false;
1106        }
1107    }
1108
1109    public function toXhtml(): string
1110    {
1111        try {
1112            $tagAttributes = $this->toTagAttributes();
1113        } catch (\Exception $e) {
1114            throw new ExceptionRuntimeInternal("We couldn't output the snippet ($this). Error: {$e->getMessage()}", self::CANONICAL, $e);
1115        }
1116        $htmlElement = $this->getHtmlTag();
1117        /**
1118         * This code runs in editing mode
1119         * or if the template is not strap
1120         * No preload is then supported
1121         */
1122        if ($htmlElement === "link") {
1123            try {
1124                $relValue = $tagAttributes->getOutputAttribute("rel");
1125                $relAs = $tagAttributes->getOutputAttribute("as");
1126                if ($relValue === "preload") {
1127                    if ($relAs === "style") {
1128                        $tagAttributes->removeOutputAttributeIfPresent("rel");
1129                        $tagAttributes->addOutputAttributeValue("rel", "stylesheet");
1130                        $tagAttributes->removeOutputAttributeIfPresent("as");
1131                    }
1132                }
1133            } catch (ExceptionNotFound $e) {
1134                // rel or as was not found
1135            }
1136        }
1137        $xhtmlContent = $tagAttributes->toHtmlEnterTag($htmlElement);
1138
1139        try {
1140            $xhtmlContent .= $tagAttributes->getInnerText();
1141        } catch (ExceptionNotFound $e) {
1142            // ok
1143        }
1144        $xhtmlContent .= "</$htmlElement>";
1145        return $xhtmlContent;
1146    }
1147
1148    /**
1149     * If is not a wiki path
1150     * It can't be served
1151     *
1152     * Example from theming, ...
1153     */
1154    private function hasLocalUrl(): bool
1155    {
1156        try {
1157            $this->getLocalUrl();
1158            return true;
1159        } catch (\Exception $e) {
1160            return false;
1161        }
1162    }
1163
1164    /**
1165     * The format
1166     * for javascript as specified by [rollup](https://rollupjs.org/configuration-options/#output-format)
1167     * @param string $format
1168     * @return Snippet
1169     */
1170    public function setFormat(string $format): Snippet
1171    {
1172        $this->format = $format;
1173        return $this;
1174    }
1175
1176    /**
1177     * Retrieve the content and wrap it if necessary
1178     * to define the execution time
1179     * (ie there is no `defer` option for inline html
1180     * @throws ExceptionNotFound
1181     */
1182    private function getInnerHtml(): string
1183    {
1184        $internal = $this->getInternalInlineAndFileContent();
1185        if (
1186            $this->getExtension() === self::EXTENSION_JS
1187            && $this->format === self::IIFE_FORMAT
1188            && $this->getCritical() === false
1189        ) {
1190            $internal = <<<EOF
1191window.addEventListener('load', function () { $internal });
1192EOF;
1193        }
1194        return $internal;
1195    }
1196
1197    public function getFormat(): string
1198    {
1199        return $this->format;
1200    }
1201
1202
1203}
1204