Skip to content

Commit 7b711ae

Browse files
committed
Improve Directive usage and implementations
1 parent 32224d4 commit 7b711ae

File tree

5 files changed

+101
-41
lines changed

5 files changed

+101
-41
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/**
4+
* League.Uri (https://uri.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Uri\Components\Directives;
15+
16+
use Stringable;
17+
18+
final class Factory
19+
{
20+
/**
21+
* Parse a Directive string representation.
22+
*
23+
* A Directive syntax is name[=value] where the
24+
* separator `=` is not present when no value
25+
* is attached to it
26+
*/
27+
public static function parse(Stringable|string $directive): Directive
28+
{
29+
$directive = (string) $directive;
30+
31+
return match (true) {
32+
str_starts_with($directive, 'text=') => TextDirective::fromString($directive),
33+
default => GenericDirective::fromString($directive),
34+
};
35+
}
36+
}

components/Components/Directives/GenericDirective.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@
2323

2424
final class GenericDirective implements Directive
2525
{
26-
private function __construct(private string $name, private ?string $value = null)
27-
{
26+
/**
27+
* @param non-empty-string $name
28+
*/
29+
private function __construct(
30+
private readonly string $name,
31+
private readonly ?string $value = null,
32+
) {
2833
}
2934

3035
/**
@@ -33,7 +38,7 @@ private function __construct(private string $name, private ?string $value = null
3338
public static function fromString(Stringable|string $value): self
3439
{
3540
[$name, $value] = explode('=', (string) $value, 2) + [1 => null];
36-
(null !== $name && !str_contains($name, '&')) || throw new SyntaxError('The submitted text is not a valid directive.');
41+
(null !== $name && '' !== $name && !str_contains($name, '&')) || throw new SyntaxError('The submitted text is not a valid directive.');
3742

3843
return new self($name, $value);
3944
}

components/Components/Directives/TextDirective.php

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,20 @@ final class TextDirective implements Directive
3434
(?:,-(?<suffix>.+))? # optional suffix (to end)
3535
$/x';
3636

37+
/**
38+
* @param non-empty-string $start
39+
* @param ?non-empty-string $end
40+
* @param ?non-empty-string $prefix
41+
* @param ?non-empty-string $suffix
42+
*/
3743
public function __construct(
3844
public readonly string $start,
3945
public readonly ?string $end = null,
4046
public readonly ?string $prefix = null,
4147
public readonly ?string $suffix = null,
4248
) {
49+
('' !== $this->start && '' !== $this->end && '' !== $this->prefix && '' !== $this->suffix)
50+
|| throw new SyntaxError('The start part can not be the empty string.');
4351
}
4452

4553
/**
@@ -64,18 +72,20 @@ public static function fromValue(Stringable|string $text): self
6472
$matches['prefix'] = null;
6573
}
6674

67-
$matches['suffix'] ??= null;
75+
/** @var non-empty-string $start */
76+
$start = (string) self::decode($matches['start']);
77+
/** @var ?non-empty-string $prefix */
78+
$prefix = self::decode($matches['prefix']);
79+
/** @var ?non-empty-string $suffix */
80+
$suffix = self::decode($matches['suffix'] ?? null);
6881
$matches['end'] ??= null;
6982
if ('' === $matches['end']) {
7083
$matches['end'] = null;
7184
}
85+
/** @var ?non-empty-string $end */
86+
$end = self::decode($matches['end']);
7287

73-
return new self(
74-
(string) self::decode($matches['start']),
75-
self::decode($matches['end']),
76-
self::decode($matches['prefix']),
77-
self::decode($matches['suffix']),
78-
);
88+
return new self($start, $end, $prefix, $suffix);
7989
}
8090

8191
private static function encode(?string $value): ?string
@@ -85,7 +95,11 @@ private static function encode(?string $value): ?string
8595

8696
private static function decode(?string $value): ?string
8797
{
88-
return null !== $value ? str_replace('%20', ' ', (string) Encoder::decodeFragment($value)) : null;
98+
if (null === $value) {
99+
return null;
100+
}
101+
102+
return str_replace('%20', ' ', (string) Encoder::decodeFragment($value));
89103
}
90104

91105
public function name(): string
@@ -162,6 +176,8 @@ public function equals(mixed $directive): bool
162176
*
163177
* This method MUST retain the state of the current instance, and return
164178
* an instance that contains the new start portion.
179+
*
180+
* @param non-empty-string $text
165181
*/
166182
public function startsWith(string $text): self
167183
{
@@ -179,6 +195,8 @@ public function startsWith(string $text): self
179195
*
180196
* This method MUST retain the state of the current instance, and return
181197
* an instance that contains the new end portion.
198+
*
199+
* @param ?non-empty-string $text
182200
*/
183201
public function endsWith(?string $text): self
184202
{
@@ -196,6 +214,8 @@ public function endsWith(?string $text): self
196214
*
197215
* This method MUST retain the state of the current instance, and return
198216
* an instance that contains the new suffix portion.
217+
*
218+
* @param ?non-empty-string $text
199219
*/
200220
public function followedBy(?string $text): self
201221
{
@@ -211,6 +231,8 @@ public function followedBy(?string $text): self
211231
*
212232
* This method MUST retain the state of the current instance, and return
213233
* an instance that contains the new prefix portion.
234+
*
235+
* @param ?non-empty-string $text
214236
*/
215237
public function precededBy(?string $text): self
216238
{

components/Components/FragmentDirectives.php

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
use Countable;
1717
use IteratorAggregate;
1818
use League\Uri\Components\Directives\Directive;
19-
use League\Uri\Components\Directives\GenericDirective;
20-
use League\Uri\Components\Directives\TextDirective;
19+
use League\Uri\Components\Directives\Factory;
2120
use League\Uri\Contracts\FragmentInterface;
2221
use League\Uri\Contracts\UriComponentInterface;
2322
use League\Uri\Contracts\UriInterface;
@@ -46,12 +45,13 @@
4645
use function is_string;
4746
use function sprintf;
4847
use function str_replace;
49-
use function strlen;
5048
use function substr;
5149

5250
use const ARRAY_FILTER_USE_BOTH;
5351

5452
/**
53+
* @see https://wicg.github.io/scroll-to-text-fragment/
54+
*
5555
* @implements IteratorAggregate<int, Directive>
5656
*/
5757
final class FragmentDirectives implements FragmentInterface, IteratorAggregate, Countable
@@ -60,7 +60,7 @@ final class FragmentDirectives implements FragmentInterface, IteratorAggregate,
6060
public const SEPARATOR = '&';
6161

6262
/** @var list<Directive> */
63-
private array $directives;
63+
private readonly array $directives;
6464

6565
public function __construct(Directive|Stringable|string ...$directives)
6666
{
@@ -81,13 +81,7 @@ public static function new(Stringable|string|null $value): self
8181
$value = (string) $value;
8282
str_starts_with($value, self::DELIMITER) || throw new SyntaxError('The value "'.$value.'" is not a valid fragment directive.');
8383

84-
return new self(...array_map(
85-
self::filterDirective(...),
86-
explode(
87-
self::SEPARATOR,
88-
substr($value, strlen(self::DELIMITER))
89-
)
90-
));
84+
return new self(...explode(self::SEPARATOR, substr($value, 3)));
9185
}
9286

9387
private static function filterDirective(Directive|Stringable|string $directive): Directive
@@ -96,9 +90,7 @@ private static function filterDirective(Directive|Stringable|string $directive):
9690
return $directive;
9791
}
9892

99-
$directive = (string) $directive;
100-
101-
return str_starts_with($directive, 'text=') ? TextDirective::fromString($directive) : GenericDirective::fromString($directive);
93+
return Factory::parse($directive);
10294
}
10395

10496
public static function tryNew(Stringable|string|null $value): ?self
@@ -293,7 +285,7 @@ public function append(FragmentDirectives|Directive|Stringable|string ...$direct
293285
return $this;
294286
}
295287

296-
return new self(...$this->directives, ...array_map(self::filterDirective(...), $items));
288+
return new self(...$this->directives, ...$items);
297289
}
298290

299291
/**
@@ -314,7 +306,7 @@ public function prepend(FragmentDirectives|Directive|Stringable|string ...$direc
314306
return $this;
315307
}
316308

317-
return new self(...array_map(self::filterDirective(...), $items), ...$this->directives);
309+
return new self(...$items, ...$this->directives);
318310
}
319311

320312
/**

docs/components/7.0/fragment-directives.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,12 @@ $fragment->equals($newFragment); //returns false
103103

104104
## The supported Directives
105105

106-
The package supports the Text Directive and the Generic Directive. Both directives implement
107-
the `Directive` interface.
106+
While the `FragmentDirectives` class allows submitting Directives as string, it is highly recommended to
107+
use a dedicated class to do so, as the grammar around building or parsing directives may be complex in
108+
regard to encoding characters and/or delimiters usage.
109+
110+
The `FragmentDirectives` class supports the `TextDirective` and the `GenericDirective` classes. Both classes implement
111+
the following `Directive` interface.
108112

109113
```php
110114
Directive::name(): string
@@ -115,10 +119,10 @@ Directive::__toString(): string
115119
```
116120

117121
A directive is composed of two parts separated by the `=` separator. The name is required as it
118-
defines the directive syntax, while its value **MAY** be optional. The `name()` and `value()`
119-
methods return the **decoded** value of the directive part whereas the `toString()` method
120-
returns the encoded string representation of the full directive. The `__toString()` method
121-
is an alias of the `toString()` method.
122+
defines the directive syntax, while its value **MAY** be optional. When the value is not defined,
123+
the separator is omitted. The `name()` and `value()` methods return the **decoded** value of
124+
the directive part whereas the `toString()` method returns its encoded string representation.
125+
The `__toString()` method is an alias of the `toString()` method.
122126

123127
### Text Directive
124128

@@ -138,6 +142,9 @@ echo $directive->value(); //display "Deprecated-,attributes,attribute,-instead"
138142
echo $directive; //display "text=Deprecated-,attributes,attribute,-instead"
139143
```
140144

145+
<p class="message-notice">the <code>-</code>, <code>&</code> and <code>,</code> characters
146+
are special and <strong>must</strong> be encoded if found in the text to avoid parsing errors.</p>
147+
141148
when added in a fragment directive and applied on a webpage the text range which
142149
starts with `attributes` and ends with `attribute` and which is preceded by
143150
`Deprecated` and followed by `instead` will be highlighted. Depending on the
@@ -172,8 +179,9 @@ $directive->toString(); // returns "text=john-,y%26lo,bar,-doe"
172179

173180
### Generic Directive
174181

175-
This directive is marked generic because it has no special effect.
176-
If can only be instantiated from a directive string representation.
182+
This directive is considered **generic** because it only meets the minimal syntax requirements of a Directive.
183+
Unlike the `TextDirective` class, it does not perform any additional parsing or validation around the directive value.
184+
As a result, it can only be instantiated from a directive’s string representation.
177185

178186
```php
179187
use League\Uri\Components\Directives\GenericDirective;
@@ -183,15 +191,12 @@ $directive->value(); //returns "bar"
183191
$directive->name(); //returns "fo&o"
184192
```
185193

186-
This class holds the minimum information needed to generate a `Directive`. It's use case
187-
is to handle all the other `Directives` as long as they don't have their own specific syntax.
188-
194+
It's use case is to handle all the other `Directives` as long as they don't have their own specific syntax.
189195

190196
### Directive equality
191197

192-
the `equals()` method allows comparing two directives against their string representation.
193-
If the string representation is identical then the `equals()` method will return `true`;
194-
otherwise `false` will be returned.
198+
The `equals()` method compares two directives based on their string representations.
199+
It returns `true` if both representations are identical, and `false` otherwise.
195200

196201
```php
197202
use League\Uri\Components\Directives\GenericDirective;

0 commit comments

Comments
 (0)