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 JsonSerializable;
17
18/**
19 * Class Snippet
20 * @package ComboStrap
21 * A HTML tag:
22 *   * CSS: link for href or style with content
23 *   * Javascript: script
24 *
25 * A component to manage the extra HTML that
26 * comes from components and that should come in the head HTML node
27 *
28 */
29class Snippet implements JsonSerializable
30{
31    /**
32     * The head in css format
33     * We need to add the style node
34     */
35    const EXTENSION_CSS = "css";
36    /**
37     * The head in javascript
38     * We need to wrap it in a script node
39     */
40    const EXTENSION_JS = "js";
41
42    /**
43     * Properties of the JSON array
44     */
45    const JSON_TYPE_PROPERTY = "type"; // mandatory
46    const JSON_COMPONENT_PROPERTY = "component"; // mandatory
47    const JSON_EXTENSION_PROPERTY = "extension"; // mandatory
48    const JSON_URL_PROPERTY = "url"; // mandatory if external
49    const JSON_CRITICAL_PROPERTY = "critical";
50    const JSON_ASYNC_PROPERTY = "async";
51    const JSON_CONTENT_PROPERTY = "content";
52    const JSON_INTEGRITY_PROPERTY = "integrity";
53    const JSON_HTML_ATTRIBUTES_PROPERTY = "attributes";
54
55    /**
56     * The type identifier for a script snippet
57     * (ie inline javascript or style)
58     *
59     * To make the difference with library
60     * that have already an identifier with the url value
61     * (ie external)
62     */
63    const INTERNAL_TYPE = "internal";
64    const EXTERNAL_TYPE = "external";
65
66    /**
67     * When a snippet is scoped to the request
68     * (ie not saved with a slot)
69     *
70     * They are unique on a request scope
71     *
72     * TlDR: The snippet does not depends to a slot and cannot therefore be cached along.
73     *
74     * The code that adds this snippet is not created by the parsing of content
75     * or depends on the page.
76     *
77     * It's always called and add the snippet whatsoever.
78     * Generally, this is an action plugin with a `TPL_METAHEADER_OUTPUT` hook
79     * such as {@link Bootstrap}, {@link HistoricalBreadcrumbMenuItem},
80     * ,...
81     */
82    const REQUEST_SLOT = "request";
83
84
85    protected static $globalSnippets;
86
87    private $snippetId;
88    private $extension;
89
90    /**
91     * @var bool
92     */
93    private $critical;
94
95    /**
96     * @var string the text script / style (may be null if it's an external resources)
97     */
98    private $inlineContent;
99
100    /**
101     * @var string
102     */
103    private $url;
104    /**
105     * @var string
106     */
107    private $integrity;
108    /**
109     * @var array Extra html attributes if needed
110     */
111    private $htmlAttributes;
112
113    /**
114     * @var string ie internal or external
115     */
116    private $type;
117    /**
118     * @var string The name of the component (used for internal style sheet to retrieve the file)
119     */
120    private $componentName;
121
122    /**
123     * @var array - the slots that needs this snippet (as key to get only one snippet by scope)
124     * A special slot exists for {@link Snippet::REQUEST_SLOT}
125     * where a snippet is for the whole requested page
126     *
127     * It's also used in the cache because not all bars
128     * may render at the same time due to the other been cached.
129     *
130     * There is two scope:
131     *   * a slot - cached along the HTML
132     *   * or  {@link Snippet::REQUEST_SLOT} - never cached
133     */
134    private $slots;
135    /**
136     * @var bool run as soon as possible
137     */
138    private $async;
139
140    /**
141     * Snippet constructor.
142     */
143    public function __construct($snippetId, $mime, $type, $url, $componentId)
144    {
145        $this->snippetId = $snippetId;
146        $this->extension = $mime;
147        $this->type = $type;
148        $this->url = $url;
149        $this->componentName = $componentId;
150    }
151
152
153    public static function createInternalCssSnippet($componentId): Snippet
154    {
155        return self::getOrCreateSnippet(self::INTERNAL_TYPE, self::EXTENSION_CSS, $componentId);
156    }
157
158
159    /**
160     * @param $componentId
161     * @return Snippet
162     * @deprecated You should create a snippet with a known type, this constructor was created for refactoring
163     */
164    public static function createUnknownSnippet($componentId): Snippet
165    {
166        return new Snippet("unknown", "unknwon", "unknwon", "unknwon", $componentId);
167    }
168
169    public static function &getOrCreateSnippet(string $identifier, string $extension, string $componentId): Snippet
170    {
171
172        /**
173         * The snippet id is the url for external resources (ie external javascript / stylesheet)
174         * otherwise if it's internal, it's the component id and it's type
175         * @param string $componentId
176         * @param string $identifier
177         * @return string
178         */
179        if ($identifier === Snippet::INTERNAL_TYPE) {
180            $snippetId = $identifier . "-" . $extension . "-" . $componentId;
181            $type = self::INTERNAL_TYPE;
182            $url = null;
183        } else {
184            $type = self::EXTERNAL_TYPE;
185            $snippetId = $identifier;
186            $url = $identifier;
187        }
188        $requestedPageId = PluginUtility::getRequestedWikiId();
189        if ($requestedPageId === null) {
190            if (PluginUtility::isTest()) {
191                $requestedPageId = "test-id";
192            } else {
193                $requestedPageId = "unknown";
194                LogUtility::msg("The requested id is unknown. We couldn't scope the snippets.");
195            }
196        }
197        $snippets = &self::$globalSnippets[$requestedPageId];
198        if ($snippets === null) {
199            self::$globalSnippets = null;
200            self::$globalSnippets[$requestedPageId] = [];
201            $snippets = &self::$globalSnippets[$requestedPageId];
202        }
203        $snippet = &$snippets[$snippetId];
204        if ($snippet === null) {
205            $snippets[$snippetId] = new Snippet($snippetId, $extension, $type, $url, $componentId);
206            $snippet = &$snippets[$snippetId];
207        }
208        return $snippet;
209
210    }
211
212    public static function reset()
213    {
214        self::$globalSnippets = null;
215    }
216
217    /**
218     * @return Snippet[]|null
219     */
220    public static function getSnippets(): ?array
221    {
222        if (self::$globalSnippets === null) {
223            return null;
224        }
225        $keys = array_keys(self::$globalSnippets);
226        return self::$globalSnippets[$keys[0]];
227    }
228
229
230    /**
231     * @param $bool - if the snippet is critical, it would not be deferred or preloaded
232     * @return Snippet for chaining
233     * All css that are for animation or background for instance
234     * should not be set as critical as they are not needed to paint
235     * exactly the page
236     *
237     * If a snippet is critical, it should not be deferred
238     *
239     * By default:
240     *   * all css are critical (except animation or background stylesheet)
241     *   * all javascript are not critical
242     *
243     * This attribute is passed in the dokuwiki array
244     * The value is stored in the {@link Snippet::getCritical()}
245     */
246    public function setCritical($bool): Snippet
247    {
248        $this->critical = $bool;
249        return $this;
250    }
251
252    /**
253     * If the library does not manipulate the DOM,
254     * it can be ran as soon as possible (ie async)
255     * @param $bool
256     * @return $this
257     */
258    public function setDoesManipulateTheDomOnRun($bool): Snippet
259    {
260        $this->async = !$bool;
261        return $this;
262    }
263
264    /**
265     * @param $inlineContent - Set an inline content for a script or stylesheet
266     * @return Snippet for chaining
267     */
268    public function setInlineContent($inlineContent): Snippet
269    {
270        $this->inlineContent = $inlineContent;
271        return $this;
272    }
273
274    /**
275     * @return string
276     */
277    public function getInternalDynamicContent(): ?string
278    {
279        return $this->inlineContent;
280    }
281
282    /**
283     * @return string|null
284     */
285    public function getInternalFileContent(): ?string
286    {
287        $path = $this->getInternalFile();
288        if (!FileSystems::exists($path)) {
289            return null;
290        }
291        return FileSystems::getContent($path);
292    }
293
294    public function getInternalFile(): ?LocalPath
295    {
296        switch ($this->extension) {
297            case self::EXTENSION_CSS:
298                $extension = "css";
299                $subDirectory = "style";
300                break;
301            case self::EXTENSION_JS:
302                $extension = "js";
303                $subDirectory = "js";
304                break;
305            default:
306                $message = "Unknown snippet type ($this->extension)";
307                if (PluginUtility::isDevOrTest()) {
308                    throw new ExceptionComboRuntime($message);
309                } else {
310                    LogUtility::msg($message);
311                }
312                return null;
313        }
314        return Site::getComboResourceSnippetDirectory()
315            ->resolve($subDirectory)
316            ->resolve(strtolower($this->componentName) . ".$extension");
317    }
318
319    public function hasSlot($slot): bool
320    {
321        if ($this->slots === null) {
322            return false;
323        }
324        return key_exists($slot, $this->slots);
325    }
326
327    public function __toString()
328    {
329        return $this->snippetId . "-" . $this->extension;
330    }
331
332    public function getCritical(): bool
333    {
334        if ($this->critical === null) {
335            if ($this->extension == self::EXTENSION_CSS) {
336                // All CSS should be loaded first
337                // The CSS animation / background can set this to false
338                return true;
339            }
340            return false;
341        }
342        return $this->critical;
343    }
344
345    public function getClass(): string
346    {
347        /**
348         * The class for the snippet is just to be able to identify them
349         *
350         * The `snippet` prefix was added to be sure that the class
351         * name will not conflict with a css class
352         * Example: if you set the class to `combo-list`
353         * and that you use it in a inline `style` tag with
354         * the same class name, the inline `style` tag is not applied
355         *
356         */
357        return "snippet-" . $this->componentName . "-" . SnippetManager::COMBO_CLASS_SUFFIX;
358
359    }
360
361    /**
362     * @return string the HTML of the tag (works for now only with CSS content)
363     */
364    public function getHtmlStyleTag(): string
365    {
366        $content = $this->getInternalInlineAndFileContent();
367        $class = $this->getClass();
368        return <<<EOF
369<style class="$class">
370$content
371</style>
372EOF;
373
374    }
375
376    public function getId()
377    {
378        return $this->snippetId;
379    }
380
381
382    public function toJsonArray(): array
383    {
384        return $this->jsonSerialize();
385
386    }
387
388    /**
389     * @throws ExceptionCombo
390     */
391    public static function createFromJson($array): Snippet
392    {
393        $snippetType = $array[self::JSON_TYPE_PROPERTY];
394        if ($snippetType === null) {
395            throw new ExceptionCombo("The snippet type property was not found in the json array");
396        }
397        switch ($snippetType) {
398            case Snippet::INTERNAL_TYPE:
399                $identifier = Snippet::INTERNAL_TYPE;
400                break;
401            case Snippet::EXTERNAL_TYPE:
402                $identifier = $array[self::JSON_URL_PROPERTY];
403                break;
404            default:
405                throw new ExceptionCombo("snippet type unknown ($snippetType");
406        }
407        $extension = $array[self::JSON_EXTENSION_PROPERTY];
408        if ($extension === null) {
409            throw new ExceptionCombo("The snippet extension property was not found in the json array");
410        }
411        $componentName = $array[self::JSON_COMPONENT_PROPERTY];
412        if ($componentName === null) {
413            throw new ExceptionCombo("The snippet component name property was not found in the json array");
414        }
415        $snippet = Snippet::getOrCreateSnippet($identifier, $extension, $componentName);
416
417
418        $critical = $array[self::JSON_CRITICAL_PROPERTY];
419        if ($critical !== null) {
420            $snippet->setCritical($critical);
421        }
422
423        $async = $array[self::JSON_ASYNC_PROPERTY];
424        if ($async !== null) {
425            $snippet->setDoesManipulateTheDomOnRun($async);
426        }
427
428        $content = $array[self::JSON_CONTENT_PROPERTY];
429        if ($content !== null) {
430            $snippet->setInlineContent($content);
431        }
432
433        $attributes = $array[self::JSON_HTML_ATTRIBUTES_PROPERTY];
434        if ($attributes !== null) {
435            foreach ($attributes as $name => $value) {
436                $snippet->addHtmlAttribute($name, $value);
437            }
438        }
439
440        $integrity = $array[self::JSON_INTEGRITY_PROPERTY];
441        if ($integrity !== null) {
442            $snippet->setIntegrity($integrity);
443        }
444
445        return $snippet;
446
447    }
448
449    public function getExtension()
450    {
451        return $this->extension;
452    }
453
454    public function setIntegrity(?string $integrity): Snippet
455    {
456        $this->integrity = $integrity;
457        return $this;
458    }
459
460    public function addHtmlAttribute(string $name, string $value): Snippet
461    {
462        $this->htmlAttributes[$name] = $value;
463        return $this;
464    }
465
466    public function addSlot(string $slot): Snippet
467    {
468        $this->slots[$slot] = 1;
469        return $this;
470    }
471
472    public function getType(): string
473    {
474        return $this->type;
475    }
476
477    public function getUrl(): string
478    {
479        return $this->url;
480    }
481
482    public function getIntegrity(): ?string
483    {
484        return $this->integrity;
485    }
486
487    public function getHtmlAttributes(): ?array
488    {
489        return $this->htmlAttributes;
490    }
491
492    public function getInternalInlineAndFileContent(): ?string
493    {
494        $totalContent = null;
495        $internalFileContent = $this->getInternalFileContent();
496        if ($internalFileContent !== null) {
497            $totalContent = $internalFileContent;
498        }
499
500        $content = $this->getInternalDynamicContent();
501        if ($content !== null) {
502            if ($totalContent === null) {
503                $totalContent = $content;
504            } else {
505                $totalContent .= $content;
506            }
507        }
508        return $totalContent;
509
510    }
511
512
513    public function jsonSerialize(): array
514    {
515        $dataToSerialize = [
516            self::JSON_COMPONENT_PROPERTY => $this->componentName,
517            self::JSON_EXTENSION_PROPERTY => $this->extension,
518            self::JSON_TYPE_PROPERTY => $this->type
519        ];
520        if ($this->url !== null) {
521            $dataToSerialize[self::JSON_URL_PROPERTY] = $this->url;
522        }
523        if ($this->integrity !== null) {
524            $dataToSerialize[self::JSON_INTEGRITY_PROPERTY] = $this->integrity;
525        }
526        if ($this->critical !== null) {
527            $dataToSerialize[self::JSON_CRITICAL_PROPERTY] = $this->critical;
528        }
529        if ($this->async !== null) {
530            $dataToSerialize[self::JSON_ASYNC_PROPERTY] = $this->async;
531        }
532        if ($this->inlineContent !== null) {
533            $dataToSerialize[self::JSON_CONTENT_PROPERTY] = $this->inlineContent;
534        }
535        if ($this->htmlAttributes !== null) {
536            $dataToSerialize[self::JSON_HTML_ATTRIBUTES_PROPERTY] = $this->htmlAttributes;
537        }
538        return $dataToSerialize;
539    }
540}
541