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

dg / texy / 29698730446

19 Jul 2026 06:14PM UTC coverage: 93.477% (+0.4%) from 93.08%
29698730446

push

github

dg
PHPStan ignore paths follow the output layer split

3296 of 3526 relevant lines covered (93.48%)

0.93 hits per line

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

80.0
/src/Texy/Modules/LinkDefinitionModule.php
1
<?php declare(strict_types=1);
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
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\Helpers;
12
use Texy\Node;
13
use Texy\Nodes;
14
use Texy\Nodes\DocumentNode;
15
use Texy\Nodes\LinkDefinitionNode;
16
use Texy\NodeTraverser;
17
use Texy\ParseContext;
18
use Texy\Patterns;
19
use Texy\Range;
20
use Texy\Syntax;
21
use function in_array, strlen;
22

23

24
/**
25
 * Processes link references and generates link elements.
26
 */
27
final class LinkDefinitionModule extends Texy\Module
28
{
29
        /** @deprecated legacy property mapping */
30
        private const LegacyProperties = [
31
                'root' => 'linkRoot',
32
                'forceNoFollow' => 'linkNoFollow',
33
                'shorten' => 'shortenUrls',
34
        ];
35

36
        /** @var array<string, LinkDefinitionNode> link definitions */
37
        private array $definitions = [];
38

39
        /** @var array<string, LinkDefinitionNode> user-defined definitions (persist across process() calls) */
40
        private array $userDefinitions = [];
41

42

43
        public function __construct(
1✔
44
                private Texy\Texy $texy,
45
        ) {
46
                $texy->allowed[Syntax::LinkDefinition] = true;
1✔
47
                $texy->allowed[Syntax::LinkReference] = false;
1✔
48
                $texy->addHandler('afterParse', $this->resolveReferences(...));
1✔
49
        }
1✔
50

51

52
        public function beforeParse(string &$text): void
1✔
53
        {
54
                // [ref]: url label .(title)[class]{style}
55
                $this->texy->registerBlockPattern(
1✔
56
                        $this->parseDefinition(...),
1✔
57
                        '~^
58
                                \[
59
                                ( [^\[\]#?*\n]{1,100} )           # reference (1)
60
                                ] : \ ++
61
                                ( \S{1,1000} )                    # URL (2)
62
                                ( [ \t] .{1,1000} )?              # optional label (3)
63
                                ' . Patterns::Modifier . '?       # modifier (4)
1✔
64
                                \s*
65
                        $~mUx',
66
                        Syntax::LinkDefinition,
1✔
67
                );
68

69
                // [reference] - bare reference link (opt-in); the raw target is kept in
70
                // LinkNode::$ref for consumer resolution passes (wiki links etc.)
71
                $this->texy->registerLinePattern(
1✔
72
                        $this->parseReference(...),
1✔
73
                        '~
1✔
74
                                (?<! [.\#\[] )        # exclude modifier .[class], PHP attribute \#[Attr] and nested [
75
                                \[
76
                                ( [^\[\]*|\n]++ )     # reference name (1); * would be an image, | a labeled form
77
                                ]
78
                        ~Ux',
79
                        Syntax::LinkReference,
1✔
80
                );
81
        }
1✔
82

83

84
        /**
85
         * Parses bare [ref]
86
         * @param  array<?string>  $matches
87
         * @param  array<?int>  $offsets
88
         */
89
        public function parseReference(ParseContext $context, array $matches, array $offsets): Nodes\LinkNode
1✔
90
        {
91
                /** @var array{string, string} $matches */
92
                [, $mRef] = $matches;
1✔
93
                $range = new Range($offsets[0], strlen($matches[0]));
1✔
94
                return new Nodes\LinkNode(
1✔
95
                        url: '[' . $mRef . ']', // bracket form is resolved against definitions like "text":[ref]
1✔
96
                        content: new Nodes\ContentNode([new Nodes\TextNode(Helpers::decodeEntities($mRef), $range)]),
1✔
97
                        range: $range,
98
                        ref: $mRef,
99
                );
100
        }
101

102

103
        /**
104
         * Parses [ref]: url
105
         * @param  array<?string>  $matches
106
         * @param  array<?int>  $offsets
107
         */
108
        public function parseDefinition(ParseContext $context, array $matches, array $offsets): LinkDefinitionNode
1✔
109
        {
110
                /** @var array{string, string, string, ?string, ?string} $matches */
111
                [, $mRef, $mLink, $mLabel, $mMod] = $matches;
1✔
112
                if ($mMod || $mLabel) {
1✔
113
                        trigger_error('Modifiers and label in link definitions are deprecated.', E_USER_DEPRECATED);
×
114
                }
115
                return new LinkDefinitionNode(
1✔
116
                        $mRef,
1✔
117
                        $mLink,
118
                        new Range($offsets[0], strlen($matches[0])),
1✔
119
                );
120
        }
121

122

123
        /**
124
         * Adds a user-defined link definition (persists across process() calls).
125
         */
126
        public function addDefinition(string $name, string $url): void
1✔
127
        {
128
                $this->userDefinitions[Helpers::toLower($name)] = new LinkDefinitionNode($name, $url);
1✔
129
        }
1✔
130

131

132
        /**
133
         * Resolve link references in the document.
134
         * Called via afterParse handler (after ImageModule).
135
         */
136
        public function resolveReferences(DocumentNode $doc): void
1✔
137
        {
138
                // Start with user-defined definitions
139
                $this->definitions = $this->userDefinitions;
1✔
140
                $traverser = new NodeTraverser;
1✔
141

142
                // Pass 1: Collect document definitions (overwrites user-defined)
143
                $traverser->traverse($doc, function (Node $node): ?int {
1✔
144
                        if ($node instanceof LinkDefinitionNode) {
1✔
145
                                $this->definitions[Helpers::toLower($node->identifier)] = $node;
1✔
146
                                return NodeTraverser::DontTraverseChildren;
1✔
147
                        }
148
                        return null;
1✔
149
                });
1✔
150

151
                // Pass 2: Resolve references in LinkNode.url
152
                $traverser->traverse($doc, function (Node $node): ?Node {
1✔
153
                        if ($node instanceof Nodes\LinkNode && $node->url !== null) {
1✔
154
                                $this->resolveLinkNodeUrl($node);
1✔
155
                        }
156
                        return null;
1✔
157
                });
1✔
158
        }
1✔
159

160

161
        /**
162
         * Resolve URL in LinkNode that might be [ref] or [*img*].
163
         */
164
        private function resolveLinkNodeUrl(Nodes\LinkNode $node): void
1✔
165
        {
166
                if ($node->url === null) {
1✔
167
                        return;
×
168
                }
169

170
                $len = strlen($node->url);
1✔
171

172
                // [*img*], [*img <], [*img >] format - resolve to image URL
173
                if ($len > 4 && $node->url[0] === '[' && $node->url[1] === '*'
1✔
174
                        && $node->url[$len - 1] === ']'
1✔
175
                        && in_array($node->url[$len - 2], ['*', '<', '>'], true)) {
1✔
176
                        $imgRef = trim(substr($node->url, 2, -2));
1✔
177
                        $node->ref = $imgRef; // keep the written reference name for analyzers
1✔
178
                        $imgDef = $this->texy->imageModule->getDefinition($imgRef);
1✔
179
                        if ($imgDef !== null && $imgDef->url !== null) {
1✔
180
                                $node->url = $imgDef->url;
1✔
181
                        } else {
182
                                // Image reference not found - use content as URL
183
                                $node->url = $imgRef;
1✔
184
                        }
185
                        $node->isImageLink = true;
1✔
186
                        return;
1✔
187
                }
188

189
                // [ref] format
190
                if ($len > 2 && $node->url[0] === '[' && $node->url[$len - 1] === ']') {
1✔
191
                        $refName = substr($node->url, 1, -1);
1✔
192
                        $node->ref = $refName; // keep the written reference name for analyzers
1✔
193
                        $def = $this->resolveDefinition($refName);
1✔
194
                        if ($def !== null) {
1✔
195
                                $node->url = $def->url;
1✔
196
                                // Check if resolved URL is email
197
                                $this->convertEmailUrl($node);
1✔
198
                                return;
1✔
199
                        }
200
                        // Reference not found - use inner content as URL
201
                        $node->url = $refName;
1✔
202
                        // Check if it's an email
203
                        $this->convertEmailUrl($node);
1✔
204
                        return;
1✔
205
                }
206

207
                // For other URLs, resolve and check for email
208
                $resolved = $this->resolveUrl($node->url);
1✔
209
                if ($resolved !== $node->url) {
1✔
210
                        $node->url = $resolved;
×
211
                }
212
                $this->convertEmailUrl($node);
1✔
213
        }
1✔
214

215

216
        /**
217
         * Convert e-mail URLs to mailto: scheme. Requires a full e-mail address
218
         * (Patterns::Email) so that wiki-style targets containing @ are left alone.
219
         */
220
        private function convertEmailUrl(Nodes\LinkNode $node): void
1✔
221
        {
222
                if ($node->url !== null
1✔
223
                        && !str_contains($node->url, '/')
1✔
224
                        && !preg_match('~^[a-z][a-z0-9+.-]*:~i', $node->url)
1✔
225
                        && Texy\Regexp::match($node->url, '~' . Patterns::Email . '(\?\S*)?$~A')) { // optional ?subject=... query
1✔
226
                        $node->url = 'mailto:' . $node->url;
1✔
227
                }
228
        }
1✔
229

230

231
        /**
232
         * Resolve URL that might be [ref] or [*img*] format.
233
         */
234
        private function resolveUrl(string $url): string
1✔
235
        {
236
                $len = strlen($url);
1✔
237

238
                // [ref] or [*img*] format
239
                if ($len > 2 && $url[0] === '[' && $url[$len - 1] === ']') {
1✔
240
                        // [*img*] → image URL
241
                        if ($url[1] === '*' && $url[$len - 2] === '*') {
×
242
                                $imgRef = trim(substr($url, 2, -2));
×
243
                                $imgDef = $this->texy->imageModule->getDefinition($imgRef);
×
244
                                if ($imgDef !== null && $imgDef->url !== null) {
×
245
                                        return $imgDef->url;
×
246
                                }
247
                                // Image reference not found - return inner content as URL
248
                                return $imgRef;
×
249
                        } else {
250
                                // [ref] → link URL
251
                                $refName = substr($url, 1, -1);
×
252
                                $def = $this->resolveDefinition($refName);
×
253
                                if ($def !== null) {
×
254
                                        return $def->url;
×
255
                                }
256
                                // Link reference not found - return inner content as URL
257
                                return $refName;
×
258
                        }
259
                }
260

261
                return $url;
1✔
262
        }
263

264

265
        /**
266
         * Find definition by identifier, supports #fragment and ?query.
267
         */
268
        private function resolveDefinition(string $identifier): ?LinkDefinitionNode
1✔
269
        {
270
                $key = Helpers::toLower($identifier);
1✔
271

272
                if (isset($this->definitions[$key])) {
1✔
273
                        return $this->definitions[$key];
1✔
274
                }
275

276
                // Support #fragment and ?query
277
                $hashPos = strpos($key, '#');
1✔
278
                $queryPos = strpos($key, '?');
1✔
279

280
                // Find the earliest delimiter
281
                $pos = null;
1✔
282
                if ($hashPos !== false && $queryPos !== false) {
1✔
283
                        $pos = min($hashPos, $queryPos);
×
284
                } elseif ($hashPos !== false) {
1✔
285
                        $pos = $hashPos;
1✔
286
                } elseif ($queryPos !== false) {
1✔
287
                        $pos = $queryPos;
1✔
288
                }
289

290
                if ($pos !== null) {
1✔
291
                        $baseKey = substr($key, 0, $pos);
1✔
292
                        if (isset($this->definitions[$baseKey])) {
1✔
293
                                $def = clone $this->definitions[$baseKey];
1✔
294
                                $def->url .= substr($identifier, $pos);
1✔
295
                                return $def;
1✔
296
                        }
297
                }
298

299
                return null;
1✔
300
        }
301

302

303
        /**
304
         * @deprecated use $texy->htmlOutput->linkRoot etc. instead
305
         */
306
        public function __get(string $name): mixed
307
        {
308
                if (isset(self::LegacyProperties[$name])) {
×
309
                        $prop = self::LegacyProperties[$name];
×
310
                        trigger_error("Property \$texy->linkModule->$name is deprecated, use \$texy->htmlOutput->$prop instead.", E_USER_DEPRECATED);
×
311
                        return $this->texy->htmlOutput->$prop;
×
312
                }
313
                throw new \LogicException("Cannot read an undeclared property \$texy->linkModule->$name.");
×
314
        }
315

316

317
        /**
318
         * @deprecated use $texy->htmlOutput->linkRoot etc. instead
319
         */
320
        public function __set(string $name, mixed $value): void
321
        {
322
                if (isset(self::LegacyProperties[$name])) {
×
323
                        $prop = self::LegacyProperties[$name];
×
324
                        trigger_error("Property \$texy->linkModule->$name is deprecated, use \$texy->htmlOutput->$prop instead.", E_USER_DEPRECATED);
×
325
                        $this->texy->htmlOutput->$prop = $value;
×
326
                        return;
×
327
                }
328
                throw new \LogicException("Cannot write to an undeclared property \$texy->linkModule->$name.");
×
329
        }
330
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc