xref: /plugin/combo/ComboStrap/LinkMarkup.php (revision 04fd306c7c155fa133ebb3669986875d65988276)
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