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

dg / texy / 21501721037

30 Jan 2026 02:00AM UTC coverage: 91.159% (-1.3%) from 92.426%
21501721037

push

github

dg
wip

2681 of 2941 relevant lines covered (91.16%)

0.91 hits per line

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

85.85
/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\Helpers;
14
use Texy\Node;
15
use Texy\Nodes;
16
use Texy\Nodes\DocumentNode;
17
use Texy\Nodes\LinkDefinitionNode;
18
use Texy\NodeTraverser;
19
use Texy\ParseContext;
20
use Texy\Patterns;
21
use Texy\Position;
22
use Texy\Syntax;
23
use function in_array, strlen;
24

25

26
/**
27
 * Processes link references and generates link elements.
28
 */
29
final class LinkModule extends Texy\Module
30
{
31
        /** @var array<string, LinkDefinitionNode> link definitions */
32
        private array $definitions = [];
33

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

37

38
        public function __construct(
1✔
39
                private Texy\Texy $texy,
40
        ) {
41
                $texy->allowed[Syntax::LinkDefinition] = true;
1✔
42
                $texy->addHandler('afterParse', $this->resolveReferences(...));
1✔
43
        }
1✔
44

45

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

64

65
        /**
66
         * Parses [ref]: url
67
         * @param  array<?string>  $matches
68
         * @param  array<?int>  $offsets
69
         */
70
        public function parseDefinition(ParseContext $context, array $matches, array $offsets): LinkDefinitionNode
1✔
71
        {
72
                [, $mRef, $mLink, $mLabel, $mMod] = $matches;
1✔
73
                if ($mMod || $mLabel) {
1✔
74
                        trigger_error('Modifiers and label in link definitions are deprecated.', E_USER_DEPRECATED);
×
75
                }
76
                return new LinkDefinitionNode(
1✔
77
                        $mRef,
1✔
78
                        $mLink,
79
                        new Position($offsets[0], strlen($matches[0])),
1✔
80
                );
81
        }
82

83

84
        /**
85
         * Adds a user-defined link definition (persists across process() calls).
86
         */
87
        public function addDefinition(string $name, string $url): void
1✔
88
        {
89
                $this->userDefinitions[Helpers::toLower($name)] = new LinkDefinitionNode($name, $url);
1✔
90
        }
1✔
91

92

93
        /**
94
         * Resolve link references in the document.
95
         * Called via afterParse handler (after ImageModule).
96
         */
97
        public function resolveReferences(DocumentNode $doc): void
1✔
98
        {
99
                // Start with user-defined definitions
100
                $this->definitions = $this->userDefinitions;
1✔
101
                $traverser = new NodeTraverser;
1✔
102

103
                // Pass 1: Collect document definitions (overwrites user-defined)
104
                $traverser->traverse($doc, function (Node $node): ?int {
1✔
105
                        if ($node instanceof LinkDefinitionNode) {
1✔
106
                                $this->definitions[Helpers::toLower($node->identifier)] = $node;
1✔
107
                                return NodeTraverser::DontTraverseChildren;
1✔
108
                        }
109
                        return null;
1✔
110
                });
1✔
111

112
                // Pass 2: Resolve references in LinkNode.url
113
                $traverser->traverse($doc, function (Node $node): ?Node {
1✔
114
                        if ($node instanceof Nodes\LinkNode && $node->url !== null) {
1✔
115
                                $this->resolveLinkNodeUrl($node);
1✔
116
                        }
117
                        return null;
1✔
118
                });
1✔
119
        }
1✔
120

121

122
        /**
123
         * Resolve URL in LinkNode that might be [ref] or [*img*].
124
         */
125
        private function resolveLinkNodeUrl(Nodes\LinkNode $node): void
1✔
126
        {
127
                if ($node->url === null) {
1✔
128
                        return;
×
129
                }
130

131
                $len = strlen($node->url);
1✔
132

133
                // [*img*], [*img <], [*img >] format - resolve to image URL
134
                if ($len > 4 && $node->url[0] === '[' && $node->url[1] === '*'
1✔
135
                        && $node->url[$len - 1] === ']'
1✔
136
                        && in_array($node->url[$len - 2], ['*', '<', '>'], true)) {
1✔
137
                        $imgRef = trim(substr($node->url, 2, -2));
1✔
138
                        $imgDef = $this->texy->imageModule->getDefinition($imgRef);
1✔
139
                        if ($imgDef !== null && $imgDef->url !== null) {
1✔
140
                                $node->url = $imgDef->url;
1✔
141
                        } else {
142
                                // Image reference not found - use content as URL
143
                                $node->url = $imgRef;
1✔
144
                        }
145
                        $node->isImageLink = true;
1✔
146
                        return;
1✔
147
                }
148

149
                // [ref] format
150
                if ($len > 2 && $node->url[0] === '[' && $node->url[$len - 1] === ']') {
1✔
151
                        $refName = substr($node->url, 1, -1);
1✔
152
                        $def = $this->resolveDefinition($refName);
1✔
153
                        if ($def !== null) {
1✔
154
                                $node->url = $def->url;
1✔
155
                                // Check if resolved URL is email
156
                                $this->convertEmailUrl($node);
1✔
157
                                return;
1✔
158
                        }
159
                        // Reference not found - use inner content as URL
160
                        $node->url = $refName;
1✔
161
                        // Check if it's an email
162
                        $this->convertEmailUrl($node);
1✔
163
                        return;
1✔
164
                }
165

166
                // For other URLs, resolve and check for email
167
                $resolved = $this->resolveUrl($node->url);
1✔
168
                if ($resolved !== $node->url) {
1✔
169
                        $node->url = $resolved;
×
170
                }
171
                $this->convertEmailUrl($node);
1✔
172
        }
1✔
173

174

175
        /**
176
         * Convert email-like URLs to mailto: scheme.
177
         */
178
        private function convertEmailUrl(Nodes\LinkNode $node): void
1✔
179
        {
180
                if ($node->url !== null
1✔
181
                        && !str_contains($node->url, '/')
1✔
182
                        && !preg_match('~^[a-z][a-z0-9+.-]*:~i', $node->url)
1✔
183
                        && preg_match('~.@.~', $node->url)) { // valid email needs chars before and after @
1✔
184
                        $node->url = 'mailto:' . $node->url;
1✔
185
                }
186
        }
1✔
187

188

189
        /**
190
         * Resolve URL that might be [ref] or [*img*] format.
191
         */
192
        private function resolveUrl(string $url): string
1✔
193
        {
194
                $len = strlen($url);
1✔
195

196
                // [ref] or [*img*] format
197
                if ($len > 2 && $url[0] === '[' && $url[$len - 1] === ']') {
1✔
198
                        // [*img*] → image URL
199
                        if ($url[1] === '*' && $url[$len - 2] === '*') {
×
200
                                $imgRef = trim(substr($url, 2, -2));
×
201
                                $imgDef = $this->texy->imageModule->getDefinition($imgRef);
×
202
                                if ($imgDef !== null && $imgDef->url !== null) {
×
203
                                        return $imgDef->url;
×
204
                                }
205
                                // Image reference not found - return inner content as URL
206
                                return $imgRef;
×
207
                        } else {
208
                                // [ref] → link URL
209
                                $refName = substr($url, 1, -1);
×
210
                                $def = $this->resolveDefinition($refName);
×
211
                                if ($def !== null) {
×
212
                                        return $def->url;
×
213
                                }
214
                                // Link reference not found - return inner content as URL
215
                                return $refName;
×
216
                        }
217
                }
218

219
                return $url;
1✔
220
        }
221

222

223
        /**
224
         * Find definition by identifier, supports #fragment and ?query.
225
         */
226
        private function resolveDefinition(string $identifier): ?LinkDefinitionNode
1✔
227
        {
228
                $key = Helpers::toLower($identifier);
1✔
229

230
                if (isset($this->definitions[$key])) {
1✔
231
                        return $this->definitions[$key];
1✔
232
                }
233

234
                // Support #fragment and ?query
235
                $hashPos = strpos($key, '#');
1✔
236
                $queryPos = strpos($key, '?');
1✔
237

238
                // Find the earliest delimiter
239
                $pos = null;
1✔
240
                if ($hashPos !== false && $queryPos !== false) {
1✔
241
                        $pos = min($hashPos, $queryPos);
×
242
                } elseif ($hashPos !== false) {
1✔
243
                        $pos = $hashPos;
1✔
244
                } elseif ($queryPos !== false) {
1✔
245
                        $pos = $queryPos;
1✔
246
                }
247

248
                if ($pos !== null) {
1✔
249
                        $baseKey = substr($key, 0, $pos);
1✔
250
                        if (isset($this->definitions[$baseKey])) {
1✔
251
                                $def = clone $this->definitions[$baseKey];
1✔
252
                                $def->url .= substr($identifier, $pos);
1✔
253
                                return $def;
1✔
254
                        }
255
                }
256

257
                return null;
1✔
258
        }
259
}
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