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

dg / texy / 29654833201

18 Jul 2026 04:09PM UTC coverage: 93.039% (+0.04%) from 93.004%
29654833201

push

github

dg
Restored test coverage dropped during the AST migration

The migration removed five test files coupled to v3 contracts (block,
content-model, formatter-indent, html, latte) with the intent to bring
them back once the output layer settled. Restored here, ported to the
AST-era API; latte and most of content-model pass unchanged.

Expected outputs were re-derived and every difference against the v3
files reviewed case by case. The differences fall into the intentional
categories: list items merge continuation lines, whitespace-sensitive
typography follows visible length instead of protection-mark key length,
rejected tags are escaped consistently (both halves of a pair) with
typography applied to the escaped text, paragraphs of escaped markup are
<p>-wrapped, and <p> inside <form> is preserved. Inner texysource
fragments are parsed without the transform phase, so their passthrough
tags stay live - a documented wrinkle.

content-model.phpt again guards the WellFormer content model, nesting
prohibitions, transparent and unknown elements and auto-closing in the
public suite, independently of the private regression corpus.

3248 of 3491 relevant lines covered (93.04%)

0.93 hits per line

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

77.78
/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\Position;
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->addHandler('afterParse', $this->resolveReferences(...));
1✔
48
        }
1✔
49

50

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

69

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

89

90
        /**
91
         * Adds a user-defined link definition (persists across process() calls).
92
         */
93
        public function addDefinition(string $name, string $url): void
1✔
94
        {
95
                $this->userDefinitions[Helpers::toLower($name)] = new LinkDefinitionNode($name, $url);
1✔
96
        }
1✔
97

98

99
        /**
100
         * Resolve link references in the document.
101
         * Called via afterParse handler (after ImageModule).
102
         */
103
        public function resolveReferences(DocumentNode $doc): void
1✔
104
        {
105
                // Start with user-defined definitions
106
                $this->definitions = $this->userDefinitions;
1✔
107
                $traverser = new NodeTraverser;
1✔
108

109
                // Pass 1: Collect document definitions (overwrites user-defined)
110
                $traverser->traverse($doc, function (Node $node): ?int {
1✔
111
                        if ($node instanceof LinkDefinitionNode) {
1✔
112
                                $this->definitions[Helpers::toLower($node->identifier)] = $node;
1✔
113
                                return NodeTraverser::DontTraverseChildren;
1✔
114
                        }
115
                        return null;
1✔
116
                });
1✔
117

118
                // Pass 2: Resolve references in LinkNode.url
119
                $traverser->traverse($doc, function (Node $node): ?Node {
1✔
120
                        if ($node instanceof Nodes\LinkNode && $node->url !== null) {
1✔
121
                                $this->resolveLinkNodeUrl($node);
1✔
122
                        }
123
                        return null;
1✔
124
                });
1✔
125
        }
1✔
126

127

128
        /**
129
         * Resolve URL in LinkNode that might be [ref] or [*img*].
130
         */
131
        private function resolveLinkNodeUrl(Nodes\LinkNode $node): void
1✔
132
        {
133
                if ($node->url === null) {
1✔
134
                        return;
×
135
                }
136

137
                $len = strlen($node->url);
1✔
138

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

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

172
                // For other URLs, resolve and check for email
173
                $resolved = $this->resolveUrl($node->url);
1✔
174
                if ($resolved !== $node->url) {
1✔
175
                        $node->url = $resolved;
×
176
                }
177
                $this->convertEmailUrl($node);
1✔
178
        }
1✔
179

180

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

194

195
        /**
196
         * Resolve URL that might be [ref] or [*img*] format.
197
         */
198
        private function resolveUrl(string $url): string
1✔
199
        {
200
                $len = strlen($url);
1✔
201

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

225
                return $url;
1✔
226
        }
227

228

229
        /**
230
         * Find definition by identifier, supports #fragment and ?query.
231
         */
232
        private function resolveDefinition(string $identifier): ?LinkDefinitionNode
1✔
233
        {
234
                $key = Helpers::toLower($identifier);
1✔
235

236
                if (isset($this->definitions[$key])) {
1✔
237
                        return $this->definitions[$key];
1✔
238
                }
239

240
                // Support #fragment and ?query
241
                $hashPos = strpos($key, '#');
1✔
242
                $queryPos = strpos($key, '?');
1✔
243

244
                // Find the earliest delimiter
245
                $pos = null;
1✔
246
                if ($hashPos !== false && $queryPos !== false) {
1✔
247
                        $pos = min($hashPos, $queryPos);
×
248
                } elseif ($hashPos !== false) {
1✔
249
                        $pos = $hashPos;
1✔
250
                } elseif ($queryPos !== false) {
1✔
251
                        $pos = $queryPos;
1✔
252
                }
253

254
                if ($pos !== null) {
1✔
255
                        $baseKey = substr($key, 0, $pos);
1✔
256
                        if (isset($this->definitions[$baseKey])) {
1✔
257
                                $def = clone $this->definitions[$baseKey];
1✔
258
                                $def->url .= substr($identifier, $pos);
1✔
259
                                return $def;
1✔
260
                        }
261
                }
262

263
                return null;
1✔
264
        }
265

266

267
        /**
268
         * @deprecated use $texy->htmlGenerator->linkRoot etc. instead
269
         */
270
        public function __get(string $name): mixed
271
        {
272
                if (isset(self::LegacyProperties[$name])) {
×
273
                        $prop = self::LegacyProperties[$name];
×
274
                        trigger_error("Property \$texy->linkModule->$name is deprecated, use \$texy->htmlGenerator->$prop instead.", E_USER_DEPRECATED);
×
275
                        return $this->texy->htmlGenerator->$prop;
×
276
                }
277
                throw new \LogicException("Cannot read an undeclared property \$texy->linkModule->$name.");
×
278
        }
279

280

281
        /**
282
         * @deprecated use $texy->htmlGenerator->linkRoot etc. instead
283
         */
284
        public function __set(string $name, mixed $value): void
285
        {
286
                if (isset(self::LegacyProperties[$name])) {
×
287
                        $prop = self::LegacyProperties[$name];
×
288
                        trigger_error("Property \$texy->linkModule->$name is deprecated, use \$texy->htmlGenerator->$prop instead.", E_USER_DEPRECATED);
×
289
                        $this->texy->htmlGenerator->$prop = $value;
×
290
                        return;
×
291
                }
292
                throw new \LogicException("Cannot write to an undeclared property \$texy->linkModule->$name.");
×
293
        }
294
}
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