1<?php 2/** 3 * Copyright (c) 2020. 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 ComboStrap\Meta\Field\PageTemplateName; 17use ComboStrap\TagAttribute\StyleAttribute; 18use Doku_Renderer_xhtml; 19use dokuwiki\Extension\PluginTrait; 20use dokuwiki\Utf8\Conversion; 21use syntax_plugin_combo_link; 22 23 24/** 25 * 26 * @package ComboStrap 27 * 28 * Parse the ref found in a markup link 29 * and return an {@link LinkMarkup::toAttributes()} array for an anchor (a) 30 * with href, style, ... attributes 31 * 32 */ 33class LinkMarkup 34{ 35 36 37 /** 38 * Class added to the type of link 39 * Class have styling rule conflict, they are by default not set 40 * but this configuration permits to turn it back 41 */ 42 const CONF_USE_DOKUWIKI_CLASS_NAME = "useDokuwikiLinkClassName"; 43 44 /** 45 * This configuration will set for all internal link 46 * the {@link LinkMarkup::PREVIEW_ATTRIBUTE} preview attribute 47 */ 48 const CONF_PREVIEW_LINK = "previewLink"; 49 const CONF_PREVIEW_LINK_DEFAULT = 0; 50 51 52 const TEXT_ERROR_CLASS = "text-danger"; 53 54 /** 55 * The known parameters for an email url 56 * The other are styling attribute :) 57 */ 58 const EMAIL_VALID_PARAMETERS = ["subject"]; 59 60 /** 61 * If set, it will show a page preview 62 */ 63 const PREVIEW_ATTRIBUTE = "preview"; 64 65 66 /** 67 * Highlight Key 68 * Adding this property to the internal query will highlight the words 69 * 70 * See {@link html_hilight} 71 */ 72 const SEARCH_HIGHLIGHT_QUERY_PROPERTY = "s"; 73 const DATA_WIKI_ID = "data-wiki-id"; 74 75 /** 76 * For styling on the anchor tag (ie a) 77 */ 78 public const ANCHOR_HTML_SNIPPET_ID = "anchor-branding"; 79 80 /** 81 * Url properties 82 * that are not seen as styling properties 83 * for a page 84 * We could also build the {@link FetcherPage} 85 * and see which attributes were not taken ? 86 */ 87 const PROTECTED_URL_PROPERTY = [ 88 self::SEARCH_HIGHLIGHT_QUERY_PROPERTY, 89 DokuWikiId::DOKUWIKI_ID_ATTRIBUTE, 90 PageTemplateName::PROPERTY_NAME, 91 FetcherPage::PURGE 92 ]; 93 94 95 private MarkupRef $markupRef; 96 97 98 private TagAttributes $stylingAttributes; 99 100 /** 101 * Link constructor. 102 * @param String $ref 103 * @throws ExceptionBadArgument 104 * @throws ExceptionBadSyntax 105 * @throws ExceptionNotFound 106 */ 107 public function __construct(string $ref) 108 { 109 110 $this->stylingAttributes = TagAttributes::createEmpty(syntax_plugin_combo_link::TAG); 111 112 $this->markupRef = MarkupRef::createLinkFromRef($ref); 113 114 $this->collectStylingAttributeInUrl(); 115 116 117 } 118 119 public static function createFromPageIdOrPath($id): LinkMarkup 120 { 121 WikiPath::addRootSeparatorIfNotPresent($id); 122 try { 123 return new LinkMarkup($id); 124 } catch (ExceptionBadArgument|ExceptionBadSyntax|ExceptionNotFound $e) { 125 throw new ExceptionRuntime("Internal error: an id should be a good reference"); 126 } 127 } 128 129 /** 130 * @throws ExceptionBadArgument 131 * @throws ExceptionBadSyntax 132 * @throws ExceptionNotFound 133 */ 134 public static function createFromRef(string $ref): LinkMarkup 135 { 136 return new LinkMarkup($ref); 137 } 138 139 public static function getHtmlClassLocalLink(): string 140 { 141 return "link-local"; 142 } 143 144 145 /** 146 * 147 * @throws ExceptionNotFound 148 */ 149 public function toAttributes(): TagAttributes 150 { 151 152 $outputAttributes = $this->stylingAttributes; 153 154 155 $url = $this->getMarkupRef()->getUrl(); 156 $outputAttributes->addOutputAttributeValue("href", $url->toString()); 157 158 /** 159 * The search term 160 * Code adapted found at {@link Doku_Renderer_xhtml::internallink()} 161 * We can't use the previous {@link wl function} 162 * because it encode too much 163 */ 164 $snippetSystem = PluginUtility::getSnippetManager(); 165 if ($url->hasProperty(self::SEARCH_HIGHLIGHT_QUERY_PROPERTY)) { 166 $snippetSystem->attachCssInternalStyleSheet("search-hit"); 167 } 168 169 /** 170 * Default Link Color 171 * Saturation and lightness comes from the 172 * Note: 173 * * blue color of Bootstrap #0d6efd s: 98, l: 52 174 * * blue color of twitter #1d9bf0 s: 88, l: 53 175 * * reddit gray with s: 16, l : 31 176 * * the text is s: 11, l: 15 177 * We choose the gray/tone rendering to be close to black 178 * the color of the text 179 */ 180 try { 181 $primaryColor = ExecutionContext::getActualOrCreateFromEnv()->getConfig()->getPrimaryColor(); 182 } catch (ExceptionNotFound $e) { 183 $primaryColor = null; 184 } 185 if (Site::isBrandingColorInheritanceEnabled() && $primaryColor !== null) { 186 187 $primaryColorText = ColorSystem::toTextColor($primaryColor); 188 $primaryColorHoverText = ColorSystem::toTextHoverColor($primaryColor); 189 /** 190 * There is also a link primary 191 * https://getbootstrap.com/docs/5.2/helpers/colored-links/ 192 */ 193 $aCss = <<<EOF 194.link-primary { color: {$primaryColorText->toRgbHex()}; } 195.link-primary:hover { color: {$primaryColorHoverText->toRgbHex()}; } 196main a { color: {$primaryColorText->toRgbHex()}; } 197main a:hover { color: {$primaryColorHoverText->toRgbHex()}; } 198EOF; 199 SnippetSystem::getFromContext()->attachCssInternalStylesheet(self::ANCHOR_HTML_SNIPPET_ID, $aCss); 200 201 } 202 203 204 global $conf; 205 206 207 /** 208 * Processing by type 209 */ 210 switch ($this->getMarkupRef()->getSchemeType()) { 211 case MarkupRef::INTERWIKI_URI: 212 try { 213 $interWiki = $this->getMarkupRef()->getInterWiki(); 214 } catch (ExceptionNotFound $e) { 215 LogUtility::internalError("The interwiki should be available. We were unable to create the link attributes."); 216 return $outputAttributes; 217 } 218 // normal link for the `this` wiki 219 if ($interWiki->getWiki() !== "this") { 220 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI); 221 } 222 $cssRules = $interWiki->getDefaultCssRules(); 223 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI, $cssRules); 224 try { 225 $cssRules = $interWiki->getSpecificCssRules(); 226 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI . "-" . $interWiki->getWiki(), $cssRules); 227 } catch (ExceptionNotFound $e) { 228 // no media find for the wiki 229 } 230 /** 231 * Target 232 */ 233 $interWikiConf = $conf['target']['interwiki']; 234 if (!empty($interWikiConf)) { 235 $outputAttributes->addOutputAttributeValue('target', $interWikiConf); 236 $outputAttributes->addOutputAttributeValue('rel', 'noopener'); 237 } 238 $outputAttributes->addClassName($interWiki->getComponentClass()); 239 $outputAttributes->addClassName($interWiki->getSubComponentClass()); 240 break; 241 case MarkupRef::WIKI_URI: 242 /** 243 * Derived from {@link Doku_Renderer_xhtml::internallink()} 244 */ 245 // https://www.dokuwiki.org/config:target 246 $target = $conf['target']['wiki']; 247 if (!empty($target)) { 248 $outputAttributes->addOutputAttributeValue('target', $target); 249 } 250 /** 251 * Internal Page 252 */ 253 try { 254 $dokuPath = $this->getMarkupRef()->getPath(); 255 } catch (ExceptionNotFound $e) { 256 throw new ExceptionNotFound("We were unable to process the internal link dokuwiki id on the link. The path was not found. Error: {$e->getMessage()}"); 257 } 258 $page = MarkupPath::createPageFromPathObject($dokuPath); 259 $outputAttributes->addOutputAttributeValue(self::DATA_WIKI_ID, $dokuPath->getWikiId()); 260 261 262 if (!FileSystems::exists($dokuPath)) { 263 264 /** 265 * Red color 266 * if not `do=edit` 267 */ 268 if (!$this->markupRef->getUrl()->hasProperty("do")) { 269 $outputAttributes->addClassName(self::getHtmlClassNotExist()); 270 $outputAttributes->addOutputAttributeValue("rel", 'nofollow'); 271 } 272 273 } else { 274 275 /** 276 * Internal Link Class 277 */ 278 $outputAttributes->addClassName(self::getHtmlClassInternalLink()); 279 280 /** 281 * Link Creation 282 * Do we need to set the title or the tooltip 283 * Processing variables 284 */ 285 $acronym = ""; 286 287 /** 288 * Preview tooltip 289 */ 290 $previewConfig = SiteConfig::getConfValue(self::CONF_PREVIEW_LINK, self::CONF_PREVIEW_LINK_DEFAULT); 291 $preview = $outputAttributes->hasComponentAttributeAndRemove(self::PREVIEW_ATTRIBUTE); 292 if ($preview || $previewConfig === 1) { 293 Tooltip::addToolTipSnippetIfNeeded(); 294 // We use as heading, the name and not the title of the resource because otherwise it would be to lengthy 295 $tooltipHtml = <<<EOF 296<h3>{$page->getNameOrDefault()}</h3> 297<p>{$page->getDescriptionOrElseDokuWiki()}</p> 298EOF; 299 $dataAttributeNamespace = Bootstrap::getDataNamespace(); 300 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-toggle", "tooltip"); 301 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-placement", "top"); 302 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-html", "true"); 303 $outputAttributes->addOutputAttributeValue("title", $tooltipHtml); 304 } 305 306 /** 307 * Low quality Page 308 * (It has a higher priority than preview and 309 * the code comes then after) 310 */ 311 if ($page->isLowQualityPage()) { 312 313 /** 314 * Add a class to style it differently 315 * (the acronym is added to the description, later) 316 */ 317 $acronym = LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM; 318 $lowerCaseLowQualityAcronym = strtolower(LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM); 319 $outputAttributes->addClassName(StyleAttribute::addComboStrapSuffix(LowQualityPage::CLASS_SUFFIX)); 320 $snippetLowQualityPageId = $lowerCaseLowQualityAcronym; 321 $snippetSystem->attachCssInternalStyleSheet($snippetLowQualityPageId); 322 /** 323 * Note The protection does occur on Javascript level, not on the HTML 324 * because the created page is valid for a anonymous or logged-in user 325 * Javascript is controlling 326 */ 327 if (LowQualityPage::isProtectionEnabled()) { 328 329 $linkType = LowQualityPage::getLowQualityLinkType(); 330 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, $linkType); 331 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLowQualityAcronym); 332 333 /** 334 * Low Quality Page protection javascript is only for warning or login link 335 */ 336 if (in_array($linkType, [PageProtection::PAGE_PROTECTION_LINK_WARNING, PageProtection::PAGE_PROTECTION_LINK_LOGIN])) { 337 PageProtection::addPageProtectionSnippet(); 338 } 339 340 } 341 } 342 343 /** 344 * Late publication has a higher priority than 345 * the late publication and the is therefore after 346 * (In case this a low quality page late published) 347 */ 348 if ($page->isLatePublication()) { 349 /** 350 * Add a class to style it differently if needed 351 */ 352 $className = StyleAttribute::addComboStrapSuffix(PagePublicationDate::LATE_PUBLICATION_CLASS_PREFIX_NAME); 353 $outputAttributes->addClassName($className); 354 if (PagePublicationDate::isLatePublicationProtectionEnabled()) { 355 $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_LINK); 356 $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_SOURCE); 357 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, PageProtection::PAGE_PROTECTION_LINK_LOGIN); 358 $acronym = PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM; 359 $lowerCaseLatePublicationAcronym = strtolower(PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM); 360 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLatePublicationAcronym); 361 PageProtection::addPageProtectionSnippet(); 362 } 363 364 } 365 366 /** 367 * Title (ie tooltip vs title html attribute) 368 */ 369 if (!$outputAttributes->hasAttribute("title")) { 370 371 $description = PageDescription::createForPage($page)->getValueOrDefault(); 372 if (!empty($acronym)) { 373 $description = $description . " ($acronym)"; 374 } 375 $outputAttributes->addOutputAttributeValue("title", $description); 376 377 } 378 379 } 380 381 break; 382 383 case MarkupRef::WINDOWS_SHARE_URI: 384 // https://www.dokuwiki.org/config:target 385 $windowsTarget = $conf['target']['windows']; 386 if (!empty($windowsTarget)) { 387 $outputAttributes->addOutputAttributeValue('target', $windowsTarget); 388 } 389 $outputAttributes->addClassName("windows"); 390 break; 391 case MarkupRef::LOCAL_URI: 392 $outputAttributes->addClassName(self::getHtmlClassLocalLink()); 393 if (!$outputAttributes->hasAttribute("title")) { 394 $description = ucfirst($this->markupRef->getUrl()->getFragment()); 395 if ($description !== "") { 396 $description = str_replace("_", " ", $description); 397 $outputAttributes->addOutputAttributeValue("title", $description); 398 } 399 } 400 break; 401 case MarkupRef::EMAIL_URI: 402 $outputAttributes->addClassName(self::getHtmlClassEmailLink()); 403 /** 404 * An email link is `<email>` 405 * {@link Emaillink::connectTo()} 406 * or 407 * {@link PluginTrait::email() 408 */ 409 // common.php#obfsucate implements the $conf['mailguard'] 410 $uri = $url->getPath(); 411 $uri = $this->obfuscateEmail($uri); 412 $uri = urlencode($uri); 413 $queryParameters = $url->getQueryProperties(); 414 if (sizeof($queryParameters) > 0) { 415 $uri .= "?"; 416 foreach ($queryParameters as $key => $value) { 417 $value = urlencode($value); 418 $key = urlencode($key); 419 if (in_array($key, self::EMAIL_VALID_PARAMETERS)) { 420 $uri .= "$key=$value"; 421 } 422 } 423 } 424 // replace href 425 $outputAttributes->removeOutputAttributeIfPresent("href"); 426 $outputAttributes->addOutputAttributeValue("href", 'mailto:' . $uri); 427 break; 428 case MarkupRef::WEB_URI: 429 /** 430 * It may be a absolute url 431 * that points to the local website 432 * (case of the {@link \syntax_plugin_combo_permalink} 433 */ 434 if ($url->isExternal()) { 435 436 if ($conf['relnofollow']) { 437 $outputAttributes->addOutputAttributeValue("rel", 'nofollow ugc'); 438 } 439 // https://www.dokuwiki.org/config:target 440 $externTarget = $conf['target']['extern']; 441 if (!empty($externTarget)) { 442 $outputAttributes->addOutputAttributeValue('target', $externTarget); 443 $outputAttributes->addOutputAttributeValue("rel", 'noopener'); 444 } 445 /** 446 * Default class for default external link 447 * To not interfere with other external link style 448 * For instance, {@link \syntax_plugin_combo_share} 449 */ 450 $outputAttributes->addClassName(self::getHtmlClassExternalLink()); 451 } 452 break; 453 default: 454 /** 455 * May be any external link 456 * such as {@link \syntax_plugin_combo_share} 457 */ 458 break; 459 460 } 461 462 /** 463 * An email URL and title 464 * may be already encoded because of the vanguard configuration 465 * 466 * The url is not treated as an attribute 467 * because the transformation function encodes the value 468 * to mitigate XSS 469 * 470 */ 471 if ($this->getMarkupRef()->getSchemeType() == MarkupRef::EMAIL_URI) { 472 $emailAddress = $this->obfuscateEmail($this->markupRef->getUrl()->getPath()); 473 $outputAttributes->addOutputAttributeValue("title", $emailAddress); 474 } 475 476 477 /** 478 * Return 479 */ 480 return $outputAttributes; 481 482 483 } 484 485 486 /** 487 * The label inside the anchor tag if there is none 488 * @param false $navigation 489 * @return string 490 * @throws ExceptionNotFound|ExceptionBadArgument 491 * 492 */ 493 public 494 function getDefaultLabel(bool $navigation = false): string 495 { 496 497 switch ($this->getMarkupRef()->getSchemeType()) { 498 case MarkupRef::WIKI_URI: 499 $page = $this->getPage(); 500 if ($navigation) { 501 return ResourceName::createForResource($page)->getValueOrDefault(); 502 } else { 503 return PageTitle::createForMarkup($page)->getValueOrDefault(); 504 } 505 case MarkupRef::EMAIL_URI: 506 global $conf; 507 $email = $this->markupRef->getUrl()->getPath(); 508 switch ($conf['mailguard']) { 509 case 'none' : 510 return $email; 511 case 'visible' : 512 default : 513 $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); 514 return strtr($email, $obfuscate); 515 } 516 case MarkupRef::INTERWIKI_URI: 517 try { 518 $path = $this->markupRef->getInterWiki()->toUrl()->getPath(); 519 if ($path[0] === "/") { 520 return substr($path, 1); 521 } else { 522 return $path; 523 } 524 } catch (ExceptionBadSyntax|ExceptionNotFound $e) { 525 return "interwiki"; 526 } 527 case MarkupRef::LOCAL_URI: 528 return $this->markupRef->getUrl()->getFragment(); 529 default: 530 return $this->markupRef->getRef(); 531 } 532 } 533 534 535 private 536 function obfuscateEmail($email, $inAttribute = true): string 537 { 538 /** 539 * adapted from {@link obfuscate()} in common.php 540 */ 541 global $conf; 542 543 $mailGuard = $conf['mailguard']; 544 if ($mailGuard === "hex" && $inAttribute) { 545 $mailGuard = "visible"; 546 } 547 switch ($mailGuard) { 548 case 'visible' : 549 $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); 550 return strtr($email, $obfuscate); 551 552 case 'hex' : 553 return Conversion::toHtml($email, true); 554 555 case 'none' : 556 default : 557 return $email; 558 } 559 } 560 561 562 /** 563 * @return bool 564 * @deprecated should not be here ref does not have the notion of relative 565 */ 566 public 567 function isRelative(): bool 568 { 569 return strpos($this->getMarkupRef()->getRef(), WikiPath::NAMESPACE_SEPARATOR_DOUBLE_POINT) !== 0; 570 } 571 572 public 573 function getMarkupRef(): MarkupRef 574 { 575 return $this->markupRef; 576 } 577 578 579 public 580 static function getHtmlClassInternalLink(): string 581 { 582 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 583 if ($oldClassName) { 584 return "wikilink1"; 585 } else { 586 return "link-internal"; 587 } 588 } 589 590 public 591 static function getHtmlClassEmailLink(): string 592 { 593 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 594 if ($oldClassName) { 595 return "mail"; 596 } else { 597 return "link-mail"; 598 } 599 } 600 601 public static function getHtmlClassExternalLink(): string 602 { 603 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 604 if ($oldClassName) { 605 return "urlextern"; 606 } else { 607 return "link-external"; 608 } 609 } 610 611//FYI: exist in dokuwiki is "wikilink1 but we let the control to the user 612 public 613 static function getHtmlClassNotExist(): string 614 { 615 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 616 if ($oldClassName) { 617 return "wikilink2"; 618 } else { 619 return self::TEXT_ERROR_CLASS; 620 } 621 } 622 623 public 624 function __toString() 625 { 626 return $this->getMarkupRef()->getRef(); 627 } 628 629 630 /** 631 * @throws ExceptionNotFound 632 */ 633 private 634 function getPage(): MarkupPath 635 { 636 return MarkupPath::createPageFromPathObject($this->getMarkupRef()->getPath()); 637 } 638 639 /** 640 * Styling attribute 641 * may be passed via parameters 642 * for internal link 643 * We don't want the styling attribute 644 * in the URL 645 */ 646 private 647 function collectStylingAttributeInUrl() 648 { 649 650 651 /** 652 * We will not overwrite the parameters if this is an dokuwiki 653 * action link (with the `do` property) 654 */ 655 if ($this->markupRef->getUrl()->hasProperty("do")) { 656 return; 657 } 658 659 /** 660 * Add the attribute from the URL 661 * if this is not a `do` 662 */ 663 switch ($this->markupRef->getSchemeType()) { 664 case MarkupRef::WIKI_URI: 665 foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) { 666 if (!in_array($key, self::PROTECTED_URL_PROPERTY)) { 667 $this->getMarkupRef()->getUrl()->deleteQueryParameter($key); 668 if (!TagAttributes::isEmptyValue($value)) { 669 $this->stylingAttributes->addComponentAttributeValue($key, $value); 670 } else { 671 $this->stylingAttributes->addEmptyComponentAttributeValue($key); 672 } 673 } 674 } 675 break; 676 case 677 MarkupRef::EMAIL_URI: 678 foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) { 679 if (!in_array($key, self::EMAIL_VALID_PARAMETERS)) { 680 $this->stylingAttributes->addComponentAttributeValue($key, $value); 681 } 682 } 683 break; 684 } 685 686 } 687 688 /** 689 * @return TagAttributes - the unknown attributes in a url are collected as styling attributes if this not a do query 690 * by {@link LinkMarkup::collectStylingAttributeInUrl()} 691 */ 692 public 693 function getStylingAttributes(): TagAttributes 694 { 695 return $this->stylingAttributes; 696 } 697 698 699} 700