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