Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.74% covered (success)
98.74%
157 / 159
92.31% covered (success)
92.31%
24 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Link
98.74% covered (success)
98.74%
157 / 159
92.31% covered (success)
92.31%
24 / 26
72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 setRelation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSystemUri
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getSystemScheme
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getSystemRelativeUri
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 toHtmlAnchor
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
12
 escapeAttr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escapeUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toString
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 toStringWithoutSession
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildActionUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 toUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 hasScheme
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isInternalReference
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isRelativeSystemLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultScheme
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isSystemLink
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isHomeIndex
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHttpGetParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 setTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSEOTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 appendSessionId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getStrategyRegistry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerStrategy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureDefaultStrategies
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Link management
5 *
6 * This class wraps the needs for managing an HTML anchor, taking into account the HTML anchor creation with specific
7 * handling for mod_rewrite phpMyFAQs native support
8 *
9 * This Source Code Form is subject to the terms of the Mozilla Public License,
10 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
11 * obtain one at https://mozilla.org/MPL/2.0/.
12 *
13 * @package   phpMyFAQ
14 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
15 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
16 * @copyright 2005-2026 phpMyFAQ Team
17 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
18 * @link      https://www.phpmyfaq.de
19 * @since     2005-11-02
20 */
21
22declare(strict_types=1);
23
24namespace phpMyFAQ;
25
26use phpMyFAQ\Link\Strategy\FaqStrategy;
27use phpMyFAQ\Link\Strategy\GenericPathStrategy;
28use phpMyFAQ\Link\Strategy\NewsStrategy;
29use phpMyFAQ\Link\Strategy\SearchStrategy;
30use phpMyFAQ\Link\Strategy\ShowStrategy;
31use phpMyFAQ\Link\Strategy\SitemapStrategy;
32use phpMyFAQ\Link\Strategy\StrategyInterface;
33use phpMyFAQ\Link\Strategy\StrategyRegistry;
34use phpMyFAQ\Link\Util\LinkQueryParser;
35use phpMyFAQ\Link\Util\TitleSlugifier;
36use Symfony\Component\HttpFoundation\Request;
37
38/**
39 * Class Link
40 *
41 * @package phpMyFAQ
42 */
43class Link
44{
45    public const string LINK_AMPERSAND = '&amp;';
46
47    public const string LINK_CATEGORY = 'category/';
48
49    public const string LINK_CONTENT = 'content/';
50
51    public const string LINK_EQUAL = '=';
52
53    public const string LINK_FRAGMENT_SEPARATOR = '#';
54
55    public const string LINK_HTML_SLASH = '/';
56
57    public const string LINK_NEWS = 'news/';
58
59    public const string LINK_SITEMAP = 'sitemap/';
60
61    public const string LINK_SLASH = '/';
62
63    public const string LINK_SEARCHPART_SEPARATOR = '?';
64
65    public const string LINK_TAGS = 'tags/';
66
67    public const string LINK_INDEX_HOME = '/index.php';
68
69    public const string LINK_GET_ACTION = 'action';
70
71    public const string LINK_GET_ARTLANG = 'artlang';
72
73    public const string LINK_GET_CATEGORY = 'cat';
74
75    public const string LINK_GET_HIGHLIGHT = 'highlight';
76
77    public const string LINK_GET_ID = 'id';
78
79    public const string LINK_GET_LANG = 'lang';
80
81    public const string LINK_GET_LETTER = 'letter';
82
83    public const string LINK_GET_NEWS_ID = 'newsid';
84
85    public const string LINK_GET_NEWS_LANG = 'newslang';
86
87    public const string LINK_GET_PAGE = 'seite';
88
89    public const string LINK_GET_SIDS = 'sid';
90
91    public const string LINK_GET_TAGGING_ID = 'tagging_id';
92
93    public const string LINK_GET_LANGS = 'langs';
94
95    public const string LINK_GET_ACTION_ADD = 'add';
96
97    public const string LINK_GET_ACTION_FAQ = 'faq';
98
99    public const string LINK_GET_ACTION_ASK = 'ask';
100
101    public const string LINK_GET_ACTION_CONTACT = 'contact';
102
103    public const string LINK_GET_ACTION_GLOSSARY = 'glossary';
104
105    public const string LINK_GET_ACTION_HELP = 'help';
106
107    public const string LINK_GET_ACTION_LOGIN = 'login';
108
109    public const string LINK_GET_ACTION_NEWS = 'news';
110
111    public const string LINK_GET_ACTION_OPEN = 'open-questions';
112
113    public const string LINK_GET_ACTION_SEARCH = 'search';
114
115    public const string LINK_GET_ACTION_SITEMAP = 'sitemap';
116
117    public const string LINK_GET_ACTION_SHOW = 'show';
118
119    public const string LINK_GET_ACTION_BOOKMARKS = 'bookmarks';
120
121    /* @mago-expect lint:no-literal-password - false positive */
122    public const string LINK_GET_ACTION_PASSWORD = 'password';
123
124    public const string LINK_GET_ACTION_REGISTER = 'register';
125
126    public const string LINK_HTML_EXTENSION = '.html';
127
128    public const string LINK_HTML_ADDCONTENT = 'add-faq.html';
129
130    public const string LINK_HTML_ASK = 'add-question.html';
131
132    public const string LINK_HTML_CONTACT = 'contact.html';
133
134    public const string LINK_HTML_GLOSSARY = 'glossary.html';
135
136    public const string LINK_HTML_HELP = 'help.html';
137
138    public const string LINK_HTML_LOGIN = 'login';
139
140    public const string LINK_HTML_OPEN = 'open-questions.html';
141
142    public const string LINK_HTML_SEARCH = 'search.html';
143
144    public const string LINK_HTML_SHOW_CATEGORIES = 'show-categories.html';
145
146    public const string LINK_HTML_BOOKMARKS = 'user/bookmarks';
147
148    public const string LINK_HTML_REGISTER = 'user/register';
149
150    /* @mago-expect lint:no-literal-password - false positive */
151    public const string LINK_HTML_FORGOT_PASSWORD = 'forgot-password';
152
153    public const string ATTR_FMT = ' %s="%s"';
154
155    /**
156     * @var int[] List of allowed action parameters
157     */
158    public static array $allowedActionParameters = [
159        'add' => 1,
160        'faq' => 1,
161        'ask' => 1,
162        'attachment' => 1,
163        'contact' => 1,
164        'glossary' => 1,
165        'help' => 1,
166        'login' => 1,
167        'news' => 1,
168        'open-questions' => 1,
169        'overview' => 1,
170        /* @mago-expect lint:no-literal-password - false positive */
171        'password' => 1,
172        'privacy' => 1,
173        'register' => 1,
174        'request-removal' => 1,
175        'save' => 1,
176        'savecomment' => 1,
177        'savequestion' => 1,
178        'savevoting' => 1,
179        'search' => 1,
180        'sendmail' => 1,
181        'show' => 1,
182        'sitemap' => 1,
183        'thankyou' => 1,
184        'twofactor' => 1,
185        'ucp' => 1,
186        'bookmarks' => 1,
187    ];
188
189    /**
190     * CSS class.
191     */
192    public string $class = '';
193
194    /**
195     * Linktext.
196     */
197    public string $text = '';
198
199    /**
200     * Tooltip.
201     */
202    public ?string $tooltip = '';
203
204    /**
205     * Target.
206     */
207    public string $target = '';
208
209    /**
210     * Name selector.
211     */
212    public string $name = '';
213
214    /**
215     * property specific to the SEO/SEF URLs.
216     */
217    private string $itemTitle = '';
218
219    /**
220     * id selector.
221     */
222    public string $id = '';
223
224    /**
225     * The "rel" property.
226     */
227    protected string $rel = '';
228
229    /** Registry holding all action strategies */
230    private StrategyRegistry $strategyRegistry;
231
232    /**
233     * Constructor.
234     *
235     * @param string $url URL
236     */
237    public function __construct(
238        public string $url,
239        private readonly Configuration $configuration,
240        ?StrategyRegistry $strategyRegistry = null,
241    ) {
242        if (!$strategyRegistry instanceof StrategyRegistry) {
243            // default registry population (previous behavior)
244            $strategyRegistry = new StrategyRegistry([
245                self::LINK_GET_ACTION_FAQ => new FaqStrategy(),
246                self::LINK_GET_ACTION_SEARCH => new SearchStrategy(),
247                self::LINK_GET_ACTION_SITEMAP => new SitemapStrategy(),
248                self::LINK_GET_ACTION_SHOW => new ShowStrategy(),
249                self::LINK_GET_ACTION_NEWS => new NewsStrategy(),
250                // Simple path-based strategies
251                self::LINK_GET_ACTION_ADD => new GenericPathStrategy(self::LINK_HTML_ADDCONTENT),
252                self::LINK_GET_ACTION_ASK => new GenericPathStrategy(self::LINK_HTML_ASK),
253                self::LINK_GET_ACTION_CONTACT => new GenericPathStrategy(self::LINK_HTML_CONTACT),
254                self::LINK_GET_ACTION_GLOSSARY => new GenericPathStrategy(self::LINK_HTML_GLOSSARY),
255                self::LINK_GET_ACTION_HELP => new GenericPathStrategy(self::LINK_HTML_HELP),
256                self::LINK_GET_ACTION_OPEN => new GenericPathStrategy(self::LINK_HTML_OPEN),
257                self::LINK_GET_ACTION_LOGIN => new GenericPathStrategy(self::LINK_HTML_LOGIN),
258                self::LINK_GET_ACTION_PASSWORD => new GenericPathStrategy(self::LINK_HTML_FORGOT_PASSWORD),
259                self::LINK_GET_ACTION_BOOKMARKS => new GenericPathStrategy(self::LINK_HTML_BOOKMARKS),
260                self::LINK_GET_ACTION_REGISTER => new GenericPathStrategy(self::LINK_HTML_REGISTER),
261            ]);
262        }
263
264        if ($strategyRegistry instanceof StrategyRegistry) {
265            // Merge missing default strategies when a custom registry is injected (non-destructive)
266            $this->ensureDefaultStrategies($strategyRegistry);
267        }
268
269        $this->strategyRegistry = $strategyRegistry;
270    }
271
272    /**
273     * @param string $rel rel property
274     */
275    public function setRelation(string $rel): void
276    {
277        $this->rel = $rel;
278    }
279
280    /**
281     * Returns the system URI.
282     * HTTP_HOST is the name of the website or virtual host name (HTTP/1.1)
283     * Precisely, it contains what the user has written in the Host request-header, see below.
284     * RFC 2616: The Host request-header field specifies the Internet host and port number of the resource
285     *           being requested, as obtained from the original URI given by the user or referring resource
286     */
287    public function getSystemUri(?string $path = null): string
288    {
289        $request = Request::createFromGlobals();
290        $host = $request->getHost();
291        $host = preg_replace(pattern: ['/:80$/', '/:443$/'], replacement: '', subject: $host) ?? '';
292
293        $sysUri = $this->getSystemScheme() . $host;
294
295        return $sysUri . self::getSystemRelativeUri($path);
296    }
297
298    /**
299     * Returns the system scheme, http or https.
300     */
301    public function getSystemScheme(): string
302    {
303        $request = Request::createFromGlobals();
304
305        if ($this->configuration->get(item: 'security.useSslOnly')) {
306            return 'https://';
307        }
308
309        return $request->isSecure() ? 'https://' : 'http://';
310    }
311
312    /**
313     * Returns the relative URI.
314     */
315    public static function getSystemRelativeUri(?string $path = null): string
316    {
317        $request = Request::createFromGlobals();
318        $scriptName = $request->getScriptName();
319
320        if ($path !== null) {
321            return str_replace(search: $path, replace: '', subject: $scriptName);
322        }
323
324        return str_replace(search: '/src/Link.php', replace: '', subject: $scriptName);
325    }
326
327    /**
328     * Builds an HTML anchor.
329     */
330    public function toHtmlAnchor(): string
331    {
332        $url = $this->toString();
333        $html = '<a';
334
335        $add = function (string $attribute, string $value) use (&$html): void {
336            if ($value !== '' && $value !== '0') {
337                $html .= sprintf(self::ATTR_FMT, $attribute, $this->escapeAttr($value));
338            }
339        };
340
341        $add(attribute: 'class', value: $this->class);
342        $add(attribute: 'id', value: $this->id);
343        $add(attribute: 'title', value: $this->tooltip ?? '');
344        $add(attribute: 'name', value: $this->name);
345
346        if (($this->name === '' || $this->name === '0') && $this->url !== '' && $this->url !== '0') {
347            $html .= sprintf(' href="%s"', $this->escapeUrl($url));
348            $add(attribute: 'target', value: $this->target);
349        }
350
351        $add(attribute: 'rel', value: $this->rel);
352
353        $html .= '>';
354
355        $body = $this->text;
356
357        if ($body === '' || $body === '0') {
358            $body = $this->name !== '' && $this->name !== '0' ? $this->name : $url;
359        }
360
361        $html .= $body === $url ? $this->escapeUrl($body) : $this->escapeAttr($body);
362
363        return $html . '</a>';
364    }
365
366    private function escapeAttr(string $value): string
367    {
368        return Strings::htmlentities($value);
369    }
370
371    private function escapeUrl(string $value): string
372    {
373        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, encoding: 'UTF-8');
374    }
375
376    /**
377     * Rewrites a URL string. Checks mod_rewrite support and 'rewrite'
378     * the passed (system) uri according to the rewrite rules written
379     * in .htaccess
380     */
381    public function toString(): string
382    {
383        $url = $this->toUri();
384
385        if (!$this->isHomeIndex()) {
386            return $url;
387        }
388
389        $p = $this->getHttpGetParameters();
390        if (!array_key_exists(self::LINK_GET_ACTION, $p)) {
391            return $url;
392        }
393
394        $url = substr($url, offset: 0, length: (int) strpos($url, self::LINK_INDEX_HOME) + 1);
395        $action = $p[self::LINK_GET_ACTION];
396
397        $built = $this->buildActionUrl($action, $p);
398        if ($built !== null) {
399            $url .= $built;
400        }
401
402        if (array_key_exists(self::LINK_GET_SIDS, $p)) {
403            $url = $this->appendSessionId($url, (int) $p[self::LINK_GET_SIDS]);
404        }
405
406        if (array_key_exists('fragment', $p)) {
407            $url .= self::LINK_FRAGMENT_SEPARATOR . $p['fragment'];
408        }
409
410        return $url;
411    }
412
413    /**
414     * Returns URL without query string (legacy behavior when $removeSessionFromUrl was true).
415     */
416    public function toStringWithoutSession(): string
417    {
418        $url = $this->toString();
419        /* @mago-expect lint:no-literal-password - false positive */
420        $urlWithoutQueryString = strtok($url, token: '?');
421
422        return $urlWithoutQueryString === false ? '' : $urlWithoutQueryString;
423    }
424
425    /**
426     * Dispatcher for action-specific URL components.
427     *
428     * @param array<string,string> $p
429     */
430    private function buildActionUrl(string $action, array $p): ?string
431    {
432        $strategy = $this->strategyRegistry->get($action);
433        if ($strategy instanceof StrategyInterface) {
434            return $strategy->build($p, $this);
435        }
436
437        return null;
438    }
439
440    /**
441     * Transforms a URI.
442     */
443    public function toUri(): string
444    {
445        if ($this->url !== '' && $this->url !== '0' && (!$this->hasScheme() && !$this->isInternalReference())) {
446            return $this->getDefaultScheme() . $this->url;
447        }
448
449        return $this->url;
450    }
451
452    /**
453     * Checks if URL contains a scheme.
454     */
455    private function hasScheme(): bool
456    {
457        $parsed = parse_url($this->url);
458
459        return (
460            is_array($parsed)
461            && array_key_exists('scheme', $parsed)
462            && ($parsed['scheme'] !== '' && $parsed['scheme'] !== '0')
463        );
464    }
465
466    /**
467     * Checks if URL is an internal reference.
468     */
469    protected function isInternalReference(): bool
470    {
471        if ($this->isRelativeSystemLink()) {
472            return true;
473        }
474
475        if (!str_contains($this->url, needle: '#')) {
476            return false;
477        }
478
479        return str_starts_with($this->url, '#');
480    }
481
482    /**
483     * Checks if URL is a relative system link.
484     */
485    private function isRelativeSystemLink(): bool
486    {
487        $slashIdx = strpos($this->url, self::LINK_SLASH);
488        if (false === $slashIdx) {
489            return false;
490        }
491
492        return $slashIdx === 0;
493    }
494
495    /**
496     * Returns the default scheme.
497     */
498    protected function getDefaultScheme(): string
499    {
500        if ($this->isSystemLink()) {
501            return $this->getSystemScheme();
502        }
503
504        return 'https://';
505    }
506
507    /**
508     * Checks if URL is a system link.
509     */
510    protected function isSystemLink(): bool
511    {
512        // 1. Is the url relative, starting with '/'?
513        // 2. Is the url related to the current running phpMyFAQ installation?
514        if ($this->isRelativeSystemLink()) {
515            return true;
516        }
517
518        // HTTP_HOST is the name of the website or virtual host name
519        return str_contains($this->url, Request::createFromGlobals()->getHost());
520    }
521
522    /**
523     * Checks if the current URL is the main index.php file.
524     */
525    protected function isHomeIndex(): bool
526    {
527        if (!$this->isSystemLink()) {
528            return false;
529        }
530
531        return str_contains($this->url, self::LINK_INDEX_HOME);
532    }
533
534    /**
535     * Returns the HTTP GET parameters.
536     * @return array<string, string>
537     */
538    protected function getHttpGetParameters(): array
539    {
540        return LinkQueryParser::parse($this->url);
541    }
542
543    /**
544     * Returns the query of a URL.
545     * @return array<string, string>
546     */
547    protected function getQuery(): array
548    {
549        $query = [];
550
551        if ($this->url !== '' && $this->url !== '0') {
552            $parsed = parse_url($this->url);
553
554            if (is_array($parsed) && array_key_exists('query', $parsed)) {
555                $query['main'] = filter_var($parsed['query'], FILTER_SANITIZE_SPECIAL_CHARS);
556            }
557
558            if (is_array($parsed) && array_key_exists('fragment', $parsed)) {
559                $query['fragment'] = filter_var($parsed['fragment'], FILTER_SANITIZE_SPECIAL_CHARS);
560            }
561        }
562
563        return $query;
564    }
565
566    public function setTitle(string $title): Link
567    {
568        $this->itemTitle = $title;
569        return $this;
570    }
571
572    /**
573     * Returns a search engine optimized title.
574     */
575    public function getSEOTitle(string $title = ''): string
576    {
577        if ($title === '') {
578            $title = $this->itemTitle;
579        }
580
581        return TitleSlugifier::slug($title);
582    }
583
584    /**
585     * Appends the session id.
586     *
587     * @param string $url URL
588     * @param int $sids Session ID
589     */
590    private function appendSessionId(string $url, int $sids): string
591    {
592        $separator = str_contains($url, self::LINK_SEARCHPART_SEPARATOR)
593            ? self::LINK_AMPERSAND
594            : self::LINK_SEARCHPART_SEPARATOR;
595
596        return $url . $separator . self::LINK_GET_SIDS . self::LINK_EQUAL . $sids;
597    }
598
599    /**
600     * Returns the injected StrategyRegistry instance.
601     */
602    public function getStrategyRegistry(): StrategyRegistry
603    {
604        return $this->strategyRegistry;
605    }
606
607    /**
608     * Registers or overrides a strategy at runtime (plugin extension point).
609     */
610    public function registerStrategy(string $action, StrategyInterface $strategy): void
611    {
612        $this->strategyRegistry->register($action, $strategy);
613    }
614
615    /**
616     * Ensures that all default strategies exist in the provided registry without overriding existing entries.
617     */
618    private function ensureDefaultStrategies(StrategyRegistry $strategyRegistry): void
619    {
620        $defaults = [
621            self::LINK_GET_ACTION_FAQ => static fn(): FaqStrategy => new FaqStrategy(),
622            self::LINK_GET_ACTION_SEARCH => static fn(): SearchStrategy => new SearchStrategy(),
623            self::LINK_GET_ACTION_SITEMAP => static fn(): SitemapStrategy => new SitemapStrategy(),
624            self::LINK_GET_ACTION_SHOW => static fn(): ShowStrategy => new ShowStrategy(),
625            self::LINK_GET_ACTION_NEWS => static fn(): NewsStrategy => new NewsStrategy(),
626            // Simple path-based strategies
627            self::LINK_GET_ACTION_ADD =>
628                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_ADDCONTENT),
629            self::LINK_GET_ACTION_ASK =>
630                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_ASK),
631            self::LINK_GET_ACTION_CONTACT =>
632                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_CONTACT),
633            self::LINK_GET_ACTION_GLOSSARY =>
634                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_GLOSSARY),
635            self::LINK_GET_ACTION_HELP =>
636                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_HELP),
637            self::LINK_GET_ACTION_OPEN =>
638                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_OPEN),
639            self::LINK_GET_ACTION_LOGIN =>
640                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_LOGIN),
641            self::LINK_GET_ACTION_PASSWORD =>
642                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_FORGOT_PASSWORD),
643            self::LINK_GET_ACTION_BOOKMARKS =>
644                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_BOOKMARKS),
645            self::LINK_GET_ACTION_REGISTER =>
646                static fn(): GenericPathStrategy => new GenericPathStrategy(self::LINK_HTML_REGISTER),
647        ];
648        foreach ($defaults as $action => $factory) {
649            if ($strategyRegistry->has($action)) {
650                continue;
651            }
652
653            $strategyRegistry->register($action, $factory());
654        }
655    }
656}