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