• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / texy / 21344532034

26 Jan 2026 02:43AM UTC coverage: 91.98% (-0.4%) from 92.376%
21344532034

push

github

dg
added CLAUDE.md

2397 of 2606 relevant lines covered (91.98%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.98
/src/Texy/Modules/LinkModule.php
1
<?php
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Texy\Modules;
11

12
use Texy;
13
use Texy\HandlerInvocation;
14
use Texy\LineParser;
15
use Texy\Link;
16
use Texy\Patterns;
17
use function iconv_strlen, iconv_substr, link, preg_match, str_contains, str_replace, strlen, strncasecmp, strpos, substr, trim, urlencode;
18

19

20
/**
21
 * Processes links, email addresses, and URL references.
22
 */
23
final class LinkModule extends Texy\Module
24
{
25
        /** root of relative links */
26
        public ?string $root = null;
27

28
        /** linked image class */
29
        public ?string $imageClass = null;
30

31
        /** always use rel="nofollow" for absolute links? */
32
        public bool $forceNoFollow = false;
33

34
        /** shorten URLs to more readable form? */
35
        public bool $shorten = true;
36

37
        /** @var array<string, Link> link references */
38
        private array $references = [];
39

40
        /** @var array<string, true> */
41
        private static array $livelock;
42

43
        private static string $EMAIL;
44

45

46
        public function __construct(Texy\Texy $texy)
1✔
47
        {
48
                $this->texy = $texy;
1✔
49

50
                $texy->allowed['link/definition'] = true;
1✔
51
                $texy->addHandler('newReference', $this->solveNewReference(...));
1✔
52
                $texy->addHandler('linkReference', $this->solve(...));
1✔
53
                $texy->addHandler('linkEmail', $this->solveUrlEmail(...));
1✔
54
                $texy->addHandler('linkURL', $this->solveUrlEmail(...));
1✔
55
                $texy->addHandler('beforeParse', $this->beforeParse(...));
1✔
56

57
                // [reference]
58
                $texy->registerLinePattern(
1✔
59
                        $this->patternReference(...),
1✔
60
                        '#(\[[^\[\]\*\n' . Patterns::MARK . ']++\])#U',
1✔
61
                        'link/reference',
1✔
62
                );
63

64
                // direct url; charaters not allowed in URL <>[\]^`{|}
65
                $texy->registerLinePattern(
1✔
66
                        $this->patternUrlEmail(...),
1✔
67
                        '#(?<=^|[\s([<:\x17])(?:https?://|www\.|ftp://)[0-9.' . Patterns::CHAR . '-][/\d' . Patterns::CHAR . '+\.~%&?@=_:;\#$!,*()\x{ad}-]{1,1000}[/\d' . Patterns::CHAR . '+~?@=_\#$*]#u',
1✔
68
                        'link/url',
1✔
69
                        '#(?:https?://|www\.|ftp://)#u',
1✔
70
                );
71

72
                // direct email
73
                self::$EMAIL = '[' . Patterns::CHAR . '][0-9.+_' . Patterns::CHAR . '-]{0,63}@[0-9.+_' . Patterns::CHAR . '\x{ad}-]{1,252}\.[' . Patterns::CHAR . '\x{ad}]{2,19}';
1✔
74
                $texy->registerLinePattern(
1✔
75
                        $this->patternUrlEmail(...),
1✔
76
                        '#(?<=^|[\s([<\x17])' . self::$EMAIL . '#u',
1✔
77
                        'link/email',
1✔
78
                        '#' . self::$EMAIL . '#u',
1✔
79
                );
80
        }
1✔
81

82

83
        /**
84
         * Text pre-processing.
85
         */
86
        private function beforeParse(Texy\Texy $texy, string &$text): void
1✔
87
        {
88
                self::$livelock = [];
1✔
89

90
                // [la trine]: http://www.latrine.cz/ text odkazu .(title)[class]{style}
91
                if (!empty($texy->allowed['link/definition'])) {
1✔
92
                        $text = Texy\Regexp::replace(
1✔
93
                                $text,
1✔
94
                                '#^\[([^\[\]\#\?\*\n]{1,100})\]:\ ++(\S{1,1000})([\ \t].{1,1000})?' . Patterns::MODIFIER . '?\s*()$#mUu',
1✔
95
                                $this->patternReferenceDef(...),
1✔
96
                        );
97
                }
98
        }
1✔
99

100

101
        /**
102
         * Callback for: [la trine]: http://www.latrine.cz/ text odkazu .(title)[class]{style}.
103
         * @param  string[]  $matches
104
         */
105
        private function patternReferenceDef(array $matches): string
1✔
106
        {
107
                [, $mRef, $mLink, $mLabel, $mMod] = $matches;
1✔
108
                // [1] => [ (reference) ]
109
                // [2] => link
110
                // [3] => ...
111
                // [4] => .(title)[class]{style}
112

113
                $link = new Link($mLink);
1✔
114
                $link->label = trim($mLabel);
1✔
115
                $link->modifier->setProperties($mMod);
1✔
116
                $this->checkLink($link);
1✔
117
                $this->addReference($mRef, $link);
1✔
118
                return '';
1✔
119
        }
120

121

122
        /**
123
         * Callback for: [ref].
124
         * @param  string[]  $matches
125
         */
126
        public function patternReference(LineParser $parser, array $matches): Texy\HtmlElement|string|null
1✔
127
        {
128
                [, $mRef] = $matches;
1✔
129
                // [1] => [ref]
130

131
                $texy = $this->texy;
1✔
132
                $name = substr($mRef, 1, -1);
1✔
133
                $link = $this->getReference($name);
1✔
134

135
                if (!$link) {
1✔
136
                        return $texy->invokeAroundHandlers('newReference', $parser, [$name]);
1✔
137
                }
138

139
                $link->type = $link::BRACKET;
1✔
140

141
                if ($link->label != '') { // null or ''
1✔
142
                        // prevent circular references
143
                        assert($link->name !== null);
144
                        if (isset(self::$livelock[$link->name])) {
1✔
145
                                $content = $link->label;
×
146
                        } else {
147
                                self::$livelock[$link->name] = true;
1✔
148
                                $el = new Texy\HtmlElement;
1✔
149
                                $lineParser = new LineParser($texy, $el);
1✔
150
                                $lineParser->parse($link->label);
1✔
151
                                $content = $el->toString($texy);
1✔
152
                                unset(self::$livelock[$link->name]);
1✔
153
                        }
154
                } else {
155
                        $content = $this->textualUrl($link);
1✔
156
                        $content = $this->texy->protect($content, $texy::CONTENT_TEXTUAL);
1✔
157
                }
158

159
                return $texy->invokeAroundHandlers('linkReference', $parser, [$link, $content]);
1✔
160
        }
161

162

163
        /**
164
         * Callback for: http://davidgrudl.com david@grudl.com.
165
         * @param  string[]  $matches
166
         */
167
        public function patternUrlEmail(LineParser $parser, array $matches, string $name): Texy\HtmlElement|string|null
1✔
168
        {
169
                [$mURL] = $matches;
1✔
170
                // [0] => URL
171

172
                $link = new Link($mURL);
1✔
173
                $this->checkLink($link);
1✔
174

175
                return $this->texy->invokeAroundHandlers(
1✔
176
                        $name === 'link/email' ? 'linkEmail' : 'linkURL',
1✔
177
                        $parser,
178
                        [$link],
1✔
179
                );
180
        }
181

182

183
        /**
184
         * Adds new named reference.
185
         */
186
        public function addReference(string $name, Link $link): void
1✔
187
        {
188
                $link->name = Texy\Helpers::toLower($name);
1✔
189
                $this->references[$link->name] = $link;
1✔
190
        }
1✔
191

192

193
        /**
194
         * Returns named reference.
195
         */
196
        public function getReference(string $name): ?Link
1✔
197
        {
198
                $name = Texy\Helpers::toLower($name);
1✔
199
                if (isset($this->references[$name])) {
1✔
200
                        return clone $this->references[$name];
1✔
201

202
                } else {
203
                        $pos = strpos($name, '?');
1✔
204
                        if ($pos === false) {
1✔
205
                                $pos = strpos($name, '#');
1✔
206
                        }
207

208
                        if ($pos !== false) { // try to extract ?... #... part
1✔
209
                                $name2 = substr($name, 0, $pos);
1✔
210
                                if (isset($this->references[$name2])) {
1✔
211
                                        $link = clone $this->references[$name2];
1✔
212
                                        $link->URL .= substr($name, $pos);
1✔
213
                                        return $link;
1✔
214
                                }
215
                        }
216
                }
217

218
                return null;
1✔
219
        }
220

221

222
        public function factoryLink(string $dest, ?string $mMod, ?string $label): Link
1✔
223
        {
224
                $texy = $this->texy;
1✔
225
                $type = Link::COMMON;
1✔
226

227
                // [ref]
228
                if (strlen($dest) > 1 && $dest[0] === '[' && $dest[1] !== '*') {
1✔
229
                        $type = Link::BRACKET;
1✔
230
                        $dest = substr($dest, 1, -1);
1✔
231
                        $link = $this->getReference($dest);
1✔
232

233
                // [* image *]
234
                } elseif (strlen($dest) > 1 && $dest[0] === '[' && $dest[1] === '*') {
1✔
235
                        $type = Link::IMAGE;
1✔
236
                        $dest = trim(substr($dest, 2, -2));
1✔
237
                        $image = $texy->imageModule->getReference($dest);
1✔
238
                        if ($image) {
1✔
239
                                $link = new Link($image->linkedURL ?? $image->URL);
1✔
240
                                $link->modifier = $image->modifier;
1✔
241
                        }
242
                }
243

244
                if (empty($link)) {
1✔
245
                        $link = new Link(trim($dest));
1✔
246
                        $this->checkLink($link);
1✔
247
                }
248

249
                if (str_contains((string) $link->URL, '%s')) {
1✔
250
                        $link->URL = str_replace('%s', urlencode($texy->stringToText($label)), $link->URL);
×
251
                }
252

253
                $link->modifier->setProperties($mMod);
1✔
254
                $link->type = $type;
1✔
255
                return $link;
1✔
256
        }
257

258

259
        /**
260
         * Finish invocation.
261
         */
262
        public function solve(
1✔
263
                ?HandlerInvocation $invocation,
264
                Link $link,
265
                Texy\HtmlElement|string|null $content = null,
266
        ): Texy\HtmlElement|string|null
267
        {
268
                if ($link->URL === null) {
1✔
269
                        return $content;
1✔
270
                }
271

272
                $texy = $this->texy;
1✔
273

274
                $el = new Texy\HtmlElement('a');
1✔
275

276
                if (empty($link->modifier)) {
1✔
277
                        $nofollow = false;
×
278
                } else {
279
                        $nofollow = isset($link->modifier->classes['nofollow']);
1✔
280
                        unset($link->modifier->classes['nofollow']);
1✔
281
                        $el->attrs['href'] = null; // trick - move to front
1✔
282
                        $link->modifier->decorate($texy, $el);
1✔
283
                }
284

285
                if ($link->type === Link::IMAGE) {
1✔
286
                        // image
287
                        $el->attrs['href'] = Texy\Helpers::prependRoot($link->URL, $texy->imageModule->linkedRoot);
1✔
288
                        if ($this->imageClass) {
1✔
289
                                $el->attrs['class'] = (array) ($el->attrs['class'] ?? []);
×
290
                                $el->attrs['class'][] = $this->imageClass;
1✔
291
                        }
292
                } else {
293
                        $el->attrs['href'] = Texy\Helpers::prependRoot($link->URL, $this->root);
1✔
294

295
                        // rel="nofollow"
296
                        if ($nofollow || ($this->forceNoFollow && str_contains($el->attrs['href'], '//'))) {
1✔
297
                                $el->attrs['rel'] = 'nofollow';
1✔
298
                        }
299
                }
300

301
                if ($content !== null) {
1✔
302
                        $el->add($content);
1✔
303
                }
304

305
                $texy->summary['links'][] = $el->attrs['href'];
1✔
306

307
                return $el;
1✔
308
        }
309

310

311
        /**
312
         * Finish invocation.
313
         */
314
        private function solveUrlEmail(HandlerInvocation $invocation, Link $link): Texy\HtmlElement|string|null
1✔
315
        {
316
                $content = $this->textualUrl($link);
1✔
317
                $content = $this->texy->protect($content, Texy\Texy::CONTENT_TEXTUAL);
1✔
318
                return $this->solve(null, $link, $content);
1✔
319
        }
320

321

322
        /**
323
         * Finish invocation.
324
         */
325
        private function solveNewReference(HandlerInvocation $invocation, string $name): void
1✔
326
        {
327
                // no change
328
        }
1✔
329

330

331
        /**
332
         * Checks and corrects $URL.
333
         */
334
        private function checkLink(Link $link): void
1✔
335
        {
336
                if ($link->URL === null) {
1✔
337
                        return;
×
338
                }
339

340
                // remove soft hyphens; if not removed by Texy\Texy::process()
341
                $link->URL = str_replace("\u{AD}", '', $link->URL);
1✔
342

343
                if (strncasecmp($link->URL, 'www.', 4) === 0) {
1✔
344
                        // special supported case
345
                        $link->URL = 'http://' . $link->URL;
1✔
346

347
                } elseif (preg_match('#' . self::$EMAIL . '$#Au', $link->URL)) {
1✔
348
                        // email
349
                        $link->URL = 'mailto:' . $link->URL;
1✔
350

351
                } elseif (!$this->texy->checkURL($link->URL, Texy\Texy::FILTER_ANCHOR)) {
1✔
352
                        $link->URL = null;
1✔
353

354
                } else {
355
                        $link->URL = str_replace('&amp;', '&', $link->URL); // replace unwanted &amp;
1✔
356
                }
357
        }
1✔
358

359

360
        /**
361
         * Returns textual representation of URL.
362
         */
363
        private function textualUrl(Link $link): string
1✔
364
        {
365
                if ($this->texy->obfuscateEmail && preg_match('#^' . self::$EMAIL . '$#u', $link->raw)) { // email
1✔
366
                        return str_replace('@', '&#64;<!-- -->', $link->raw);
1✔
367
                }
368

369
                if ($this->shorten && preg_match('#^(https?://|ftp://|www\.|/)#i', $link->raw)) {
1✔
370
                        $raw = strncasecmp($link->raw, 'www.', 4) === 0
1✔
371
                                ? 'none://' . $link->raw
1✔
372
                                : $link->raw;
1✔
373

374
                        // parse_url() in PHP damages UTF-8 - use regular expression
375
                        if (!preg_match('~^(?:(?P<scheme>[a-z]+):)?(?://(?P<host>[^/?#]+))?(?P<path>(?:/|^)(?!/)[^?#]*)?(?:\?(?P<query>[^#]*))?(?:#(?P<fragment>.*))?()$~', $raw, $parts)) {
1✔
376
                                return $link->raw;
×
377
                        }
378

379
                        $res = '';
1✔
380
                        if ($parts['scheme'] !== '' && $parts['scheme'] !== 'none') {
1✔
381
                                $res .= $parts['scheme'] . '://';
1✔
382
                        }
383

384
                        if ($parts['host'] !== '') {
1✔
385
                                $res .= $parts['host'];
1✔
386
                        }
387

388
                        if ($parts['path'] !== '') {
1✔
389
                                $res .= (iconv_strlen($parts['path'], 'UTF-8') > 16 ? ("/\u{2026}" . iconv_substr($parts['path'], -12, 12, 'UTF-8')) : $parts['path']);
1✔
390
                        }
391

392
                        if ($parts['query'] !== '') {
1✔
393
                                $res .= iconv_strlen($parts['query'], 'UTF-8') > 4
1✔
394
                                        ? "?\u{2026}"
×
395
                                        : ('?' . $parts['query']);
1✔
396
                        } elseif ($parts['fragment'] !== '') {
1✔
397
                                $res .= iconv_strlen($parts['fragment'], 'UTF-8') > 4
1✔
398
                                        ? "#\u{2026}"
1✔
399
                                        : ('#' . $parts['fragment']);
1✔
400
                        }
401

402
                        return $res;
1✔
403
                }
404

405
                return $link->raw;
1✔
406
        }
407
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc