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

dg / texy / 21345344909

26 Jan 2026 03:32AM UTC coverage: 92.382% (-0.4%) from 92.744%
21345344909

push

github

dg
HtmlElement: removed toHtml() & toText()

18 of 19 new or added lines in 5 files covered. (94.74%)

149 existing lines in 21 files now uncovered.

2401 of 2599 relevant lines covered (92.38%)

0.92 hits per line

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

95.4
/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 Texy\Regexp;
18
use function iconv_strlen, iconv_substr, link, preg_match, str_contains, str_replace, strlen, strncasecmp, strpos, substr, trim, urlencode;
19

20

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

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

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

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

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

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

44
        private static string $EMAIL;
45

46

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

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

58
                // [reference]
59
                $texy->registerLinePattern(
1✔
60
                        $this->parseReference(...),
1✔
61
                        '~(
62
                                \[
63
                                [^\[\]*\n' . Patterns::MARK . ']++  # reference
1✔
64
                                ]
65
                        )~U',
66
                        'link/reference',
1✔
67
                );
68

69
                // direct url; characters not allowed in URL <>[\]^`{|}
70
                $texy->registerLinePattern(
1✔
71
                        $this->parseUrlEmail(...),
1✔
72
                        '~
73
                                (?<= ^ | [\s([<:\x17] )            # must be preceded by these chars
74
                                (?: https?:// | www\. | ftp:// )   # protocol or www
75
                                [0-9.' . Patterns::CHAR . '-]      # first char
76
                                [/\d' . Patterns::CHAR . '+.\~%&?@=_:;#$!,*()\x{ad}-]{1,1000}  # URL body
77
                                [/\d' . Patterns::CHAR . '+\~?@=_#$*]  # last char
1✔
78
                        ~',
79
                        'link/url',
1✔
80
                        '~(?: https?:// | www\. | ftp://)~',
1✔
81
                );
82

83
                // direct email
84
                self::$EMAIL = '
1✔
85
                        [' . Patterns::CHAR . ']                 # first char
86
                        [0-9.+_' . Patterns::CHAR . '-]{0,63}    # local part
87
                        @
88
                        [0-9.+_' . Patterns::CHAR . '\x{ad}-]{1,252} # domain
89
                        \.
90
                        [' . Patterns::CHAR . '\x{ad}]{2,19}     # TLD
91
                ';
92
                $texy->registerLinePattern(
1✔
93
                        $this->parseUrlEmail(...),
1✔
94
                        '~
95
                                (?<= ^ | [\s([<\x17] )             # must be preceded by these chars
96
                                ' . self::$EMAIL . '
1✔
97
                        ~',
98
                        'link/email',
1✔
99
                        '~' . self::$EMAIL . '~',
1✔
100
                );
101
        }
1✔
102

103

104
        /**
105
         * Text pre-processing.
106
         */
107
        private function beforeParse(Texy\Texy $texy, string &$text): void
1✔
108
        {
109
                self::$livelock = [];
1✔
110

111
                // [la trine]: http://www.latrine.cz/ text odkazu .(title)[class]{style}
112
                if (!empty($texy->allowed['link/definition'])) {
1✔
113
                        $text = Texy\Regexp::replace(
1✔
114
                                $text,
1✔
115
                                '~^
116
                                        \[
117
                                        ( [^\[\]#?*\n]{1,100} )           # reference (1)
118
                                        ] : \ ++
119
                                        ( \S{1,1000} )                    # URL (2)
120
                                        ( [ \t] .{1,1000} )?              # optional description (3)
121
                                        ' . Patterns::MODIFIER . '?       # modifier (4)
1✔
122
                                        \s*
123
                                $~mU',
124
                                $this->parseReferenceDef(...),
1✔
125
                        );
126
                }
127
        }
1✔
128

129

130
        /**
131
         * Callback for: [la trine]: http://www.latrine.cz/ text odkazu .(title)[class]{style}.
132
         * @param  string[]  $matches
133
         */
134
        private function parseReferenceDef(array $matches): string
1✔
135
        {
136
                [, $mRef, $mLink, $mLabel, $mMod] = $matches;
1✔
137
                // [1] => [ (reference) ]
138
                // [2] => link
139
                // [3] => ...
140
                // [4] => .(title)[class]{style}
141

142
                $link = new Link($mLink);
1✔
143
                $link->label = trim($mLabel ?? '');
1✔
144
                $link->modifier->setProperties($mMod);
1✔
145
                $this->checkLink($link);
1✔
146
                $this->addReference($mRef, $link);
1✔
147
                return '';
1✔
148
        }
149

150

151
        /**
152
         * Callback for: [ref].
153
         * @param  string[]  $matches
154
         */
155
        public function parseReference(LineParser $parser, array $matches): Texy\HtmlElement|string|null
1✔
156
        {
157
                [, $mRef] = $matches;
1✔
158
                // [1] => [ref]
159

160
                $texy = $this->texy;
1✔
161
                $name = substr($mRef, 1, -1);
1✔
162
                $link = $this->getReference($name);
1✔
163

164
                if (!$link) {
1✔
165
                        return $texy->invokeAroundHandlers('newReference', $parser, [$name]);
1✔
166
                }
167

168
                $link->type = $link::BRACKET;
1✔
169

170
                if ($link->label != '') { // null or ''
1✔
171
                        // prevent circular references
172
                        assert($link->name !== null);
173
                        if (isset(self::$livelock[$link->name])) {
1✔
UNCOV
174
                                $content = $link->label;
×
175
                        } else {
176
                                self::$livelock[$link->name] = true;
1✔
177
                                $el = new Texy\HtmlElement;
1✔
178
                                $lineParser = new LineParser($texy);
1✔
179
                                $el->inject($lineParser->parse($link->label));
1✔
180
                                $content = $texy->elemToMaskedString($el);
1✔
181
                                unset(self::$livelock[$link->name]);
1✔
182
                        }
183
                } else {
184
                        $content = $this->textualUrl($link);
1✔
185
                        $content = $this->texy->protect($content, $texy::CONTENT_TEXTUAL);
1✔
186
                }
187

188
                return $texy->invokeAroundHandlers('linkReference', $parser, [$link, $content]);
1✔
189
        }
190

191

192
        /**
193
         * Callback for: http://davidgrudl.com david@grudl.com.
194
         * @param  string[]  $matches
195
         */
196
        public function parseUrlEmail(LineParser $parser, array $matches, string $name): Texy\HtmlElement|string|null
1✔
197
        {
198
                [$mURL] = $matches;
1✔
199
                // [0] => URL
200

201
                $link = new Link($mURL);
1✔
202
                $this->checkLink($link);
1✔
203

204
                return $this->texy->invokeAroundHandlers(
1✔
205
                        $name === 'link/email' ? 'linkEmail' : 'linkURL',
1✔
206
                        $parser,
207
                        [$link],
1✔
208
                );
209
        }
210

211

212
        /**
213
         * Adds new named reference.
214
         */
215
        public function addReference(string $name, Link $link): void
1✔
216
        {
217
                $link->name = Texy\Helpers::toLower($name);
1✔
218
                $this->references[$link->name] = $link;
1✔
219
        }
1✔
220

221

222
        /**
223
         * Returns named reference.
224
         */
225
        public function getReference(string $name): ?Link
1✔
226
        {
227
                $name = Texy\Helpers::toLower($name);
1✔
228
                if (isset($this->references[$name])) {
1✔
229
                        return clone $this->references[$name];
1✔
230

231
                } else {
232
                        $pos = strpos($name, '?');
1✔
233
                        if ($pos === false) {
1✔
234
                                $pos = strpos($name, '#');
1✔
235
                        }
236

237
                        if ($pos !== false) { // try to extract ?... #... part
1✔
238
                                $name2 = substr($name, 0, $pos);
1✔
239
                                if (isset($this->references[$name2])) {
1✔
240
                                        $link = clone $this->references[$name2];
1✔
241
                                        $link->URL .= substr($name, $pos);
1✔
242
                                        return $link;
1✔
243
                                }
244
                        }
245
                }
246

247
                return null;
1✔
248
        }
249

250

251
        public function factoryLink(string $dest, ?string $mMod, ?string $label): Link
1✔
252
        {
253
                $texy = $this->texy;
1✔
254
                $type = Link::COMMON;
1✔
255

256
                // [ref]
257
                if (strlen($dest) > 1 && $dest[0] === '[' && $dest[1] !== '*') {
1✔
258
                        $type = Link::BRACKET;
1✔
259
                        $dest = substr($dest, 1, -1);
1✔
260
                        $link = $this->getReference($dest);
1✔
261

262
                // [* image *]
263
                } elseif (strlen($dest) > 1 && $dest[0] === '[' && $dest[1] === '*') {
1✔
264
                        $type = Link::IMAGE;
1✔
265
                        $dest = trim(substr($dest, 2, -2));
1✔
266
                        $image = $texy->imageModule->getReference($dest);
1✔
267
                        if ($image) {
1✔
268
                                $link = new Link($image->linkedURL ?? $image->URL);
1✔
269
                                $link->modifier = $image->modifier;
1✔
270
                        }
271
                }
272

273
                if (empty($link)) {
1✔
274
                        $link = new Link(trim($dest));
1✔
275
                        $this->checkLink($link);
1✔
276
                }
277

278
                if (str_contains((string) $link->URL, '%s')) {
1✔
NEW
279
                        $link->URL = str_replace('%s', urlencode($texy->maskedStringToText($label)), $link->URL);
×
280
                }
281

282
                $link->modifier->setProperties($mMod);
1✔
283
                $link->type = $type;
1✔
284
                return $link;
1✔
285
        }
286

287

288
        public function linkToElement(
1✔
289
                ?HandlerInvocation $invocation,
290
                Link $link,
291
                Texy\HtmlElement|string|null $content = null,
292
        ): Texy\HtmlElement|string|null
293
        {
294
                if ($link->URL === null) {
1✔
295
                        return $content;
1✔
296
                }
297

298
                $texy = $this->texy;
1✔
299

300
                $el = new Texy\HtmlElement('a');
1✔
301

302
                if (empty($link->modifier)) {
1✔
UNCOV
303
                        $nofollow = false;
×
304
                } else {
305
                        $nofollow = isset($link->modifier->classes['nofollow']);
1✔
306
                        unset($link->modifier->classes['nofollow']);
1✔
307
                        $el->attrs['href'] = null; // trick - move to front
1✔
308
                        $link->modifier->decorate($texy, $el);
1✔
309
                }
310

311
                if ($link->type === Link::IMAGE) {
1✔
312
                        // image
313
                        $el->attrs['href'] = Texy\Helpers::prependRoot($link->URL, $texy->imageModule->linkedRoot);
1✔
314
                        if ($this->imageClass) {
1✔
UNCOV
315
                                $el->attrs['class'] = (array) ($el->attrs['class'] ?? []);
×
UNCOV
316
                                $el->attrs['class'][] = $this->imageClass;
×
317
                        }
318
                } else {
319
                        $el->attrs['href'] = Texy\Helpers::prependRoot($link->URL, $this->root);
1✔
320

321
                        // rel="nofollow"
322
                        if ($nofollow || ($this->forceNoFollow && str_contains($el->attrs['href'], '//'))) {
1✔
323
                                $el->attrs['rel'] = 'nofollow';
1✔
324
                        }
325
                }
326

327
                if ($content !== null) {
1✔
328
                        $el->add($content);
1✔
329
                }
330

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

333
                return $el;
1✔
334
        }
335

336

337
        private function urlEmailToElement(HandlerInvocation $invocation, Link $link): Texy\HtmlElement|string|null
1✔
338
        {
339
                $content = $this->textualUrl($link);
1✔
340
                $content = $this->texy->protect($content, Texy\Texy::CONTENT_TEXTUAL);
1✔
341
                return $this->linkToElement(null, $link, $content);
1✔
342
        }
343

344

345
        public function newReferenceToElement(HandlerInvocation $invocation, string $name)
1✔
346
        {
347
                // no change
348
        }
1✔
349

350

351
        /**
352
         * Checks and corrects $URL.
353
         */
354
        private function checkLink(Link $link): void
1✔
355
        {
356
                if ($link->URL === null) {
1✔
UNCOV
357
                        return;
×
358
                }
359

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

363
                if (strncasecmp($link->URL, 'www.', 4) === 0) {
1✔
364
                        // special supported case
365
                        $link->URL = 'http://' . $link->URL;
1✔
366

367
                } elseif (Regexp::match($link->URL, '~' . self::$EMAIL . '$~A')) {
1✔
368
                        // email
369
                        $link->URL = 'mailto:' . $link->URL;
1✔
370

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

374
                } else {
375
                        $link->URL = str_replace('&amp;', '&', $link->URL); // replace unwanted &amp;
1✔
376
                }
377
        }
1✔
378

379

380
        /**
381
         * Returns textual representation of URL.
382
         */
383
        private function textualUrl(Link $link): string
1✔
384
        {
385
                if ($this->texy->obfuscateEmail && Regexp::match($link->raw, '~^' . self::$EMAIL . '$~')) { // email
1✔
386
                        return str_replace('@', '&#64;<!-- -->', $link->raw);
1✔
387
                }
388

389
                if ($this->shorten && Regexp::match($link->raw, '~^(https?://|ftp://|www\.|/)~i')) {
1✔
390
                        $raw = strncasecmp($link->raw, 'www.', 4) === 0
1✔
391
                                ? 'none://' . $link->raw
1✔
392
                                : $link->raw;
1✔
393

394
                        // parse_url() in PHP damages UTF-8 - use regular expression
395
                        if (!($parts = Regexp::match($raw, '~^
1✔
396
                                (?: (?P<scheme> [a-z]+ ) : )?
397
                                (?: // (?P<host> [^/?#]+ ) )?
398
                                (?P<path> (?: / | ^ ) (?! / ) [^?#]* )?
399
                                (?: \? (?P<query> [^#]* ) )?
400
                                (?: \# (?P<fragment> .* ) )?
401
                                $
402
                        ~'))) {
UNCOV
403
                                return $link->raw;
×
404
                        }
405

406
                        $res = '';
1✔
407
                        if ($parts['scheme'] !== null && $parts['scheme'] !== 'none') {
1✔
408
                                $res .= $parts['scheme'] . '://';
1✔
409
                        }
410

411
                        if ($parts['host'] !== null) {
1✔
412
                                $res .= $parts['host'];
1✔
413
                        }
414

415
                        if ($parts['path'] !== null) {
1✔
416
                                $res .= (iconv_strlen($parts['path'], 'UTF-8') > 16 ? ("/\u{2026}" . iconv_substr($parts['path'], -12, 12, 'UTF-8')) : $parts['path']);
1✔
417
                        }
418

419
                        if ($parts['query'] > '') {
1✔
420
                                $res .= iconv_strlen($parts['query'], 'UTF-8') > 4
1✔
UNCOV
421
                                        ? "?\u{2026}"
×
422
                                        : ('?' . $parts['query']);
1✔
423
                        } elseif ($parts['fragment'] > '') {
1✔
424
                                $res .= iconv_strlen($parts['fragment'], 'UTF-8') > 4
1✔
425
                                        ? "#\u{2026}"
1✔
426
                                        : ('#' . $parts['fragment']);
1✔
427
                        }
428

429
                        return $res;
1✔
430
                }
431

432
                return $link->raw;
1✔
433
        }
434
}
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