diff --git a/composer.json b/composer.json
index 34576747..d153040e 100644
--- a/composer.json
+++ b/composer.json
@@ -30,16 +30,16 @@
"ext-gmp": "*",
"ext-intl": "*",
"ext-mbstring": "*",
- "friendsofphp/php-cs-fixer": "^3.67.1",
+ "friendsofphp/php-cs-fixer": "^3.68.1",
"guzzlehttp/psr7": "^2.7.0",
"http-interop/http-factory-tests": "^2.2",
"laminas/laminas-diactoros": "^3.5.0",
"nyholm/psr7": "^1.8.2",
"phpbench/phpbench": "^1.3.1",
- "phpstan/phpstan": "^1.12.15",
+ "phpstan/phpstan": "^1.12.16",
"phpstan/phpstan-deprecation-rules": "^1.2.1",
"phpstan/phpstan-phpunit": "^1.4.2",
- "phpstan/phpstan-strict-rules": "^1.6.1",
+ "phpstan/phpstan-strict-rules": "^1.6.2",
"phpunit/phpunit": "^10.5.17 || ^11.5.3",
"psr/http-factory": "^1.1.0",
"psr/http-message": "^1.1.0 || ^2.0",
diff --git a/docs/uri/7.0/rfc3986.md b/docs/uri/7.0/rfc3986.md
index 74e4d7a2..9ab1abd7 100644
--- a/docs/uri/7.0/rfc3986.md
+++ b/docs/uri/7.0/rfc3986.md
@@ -103,7 +103,26 @@ $uri = Uri::fromRfc8089('file:/etc/fstab');
echo $uri = //returns 'file:///etc/fstab'
~~~
-
fromRfc8089
is added since version 7.4.0
+fromRfc8089
is added since version 7.4.0
+
+It is also possible to instantiate a new instance from the following HTTP related object or string>
+
+~~~php
+$uri = Uri::fromMarkdownAnchor('[overview](https://uri.thephpleague.com/uri/7.0/)');
+echo $uri; //returns 'https://uri.thephpleague.com/uri/7.0/'
+
+$uri = Uri::fromHeaderLinkValue('; rel="start"');
+echo $uri = //returns 'https://example.org/'
+
+$uri = Uri::fromHtmlAnchor('uri-hostname-parser');
+echo $uri; //returns '/domain-parser/1.0/'
+
+$uri = Uri::fromHtmlLink('');
+echo $uri = //returns '/assets/img/uri-logo.svg'
+~~~
+
+The named constructor are available since version 7.6.0
+To use the named constructor in relation to HTML tag, the ext-dom
extension must be present.
## URI string representation
@@ -147,13 +166,13 @@ HTML specific representation are added to allow adding URI to your HTML/Markdown
```php
$uri = Uri::new('eXAMPLE://a/./b/../b/%63/%7bfoo%7d?foo[]=bar');
-echo $uri->toMarkdown();
+echo $uri->toMarkdownAnchor();
//display '[example://a/b/c/{foo}?foo[]=bar](example://a/./b/../b/%63/%7bfoo%7d?foo%5B%5D=bar)
-echo $uri->toMarkdown('my link');
+echo $uri->toMarkdownAnchor('my link');
//display '[my link](example://a/./b/../b/%63/%7bfoo%7d?foo%5B%5D=bar)
-echo $uri->toAnchorTag();
+echo $uri->toHtmlAnchor();
// display 'example://a/b/c/{foo}?foo[]=bar'
-echo $uri->toAnchorTag('my link');
+echo $uri->toHtmlAnchor('my link');
// display 'my link'
```
@@ -161,9 +180,9 @@ You can also generate the Link `tag` and/or `header` depending on how you want y
```php
$uri = Uri::new('https://example.com/my/css/v1.3');
-echo $uri->toLinkTag(['rel' => 'stylesheet']);
+echo $uri->toHtmlLink(['rel' => 'stylesheet']);
//display '
-echo $uri->toLinkHeaderValue(['rel' => 'stylesheet']);
+echo $uri->toHeaderLinkValue(['rel' => 'stylesheet']);
//display 'https://example.com/my/css/v1.3 ;rel=stylesheet'
```
diff --git a/interfaces/CHANGELOG.md b/interfaces/CHANGELOG.md
index f0c1d96d..e7f62d42 100644
--- a/interfaces/CHANGELOG.md
+++ b/interfaces/CHANGELOG.md
@@ -19,6 +19,7 @@ All Notable changes to `League\Uri\Interfaces` will be documented in this file
- `UriString::removeDotSegments`
- `UriString::normalize`
- `UriString::normalizeAuthority`
+- `FeatureDetection::supportsDom`
### Fixed
diff --git a/interfaces/Contracts/UriRenderer.php b/interfaces/Contracts/UriRenderer.php
index f01d4547..92777841 100644
--- a/interfaces/Contracts/UriRenderer.php
+++ b/interfaces/Contracts/UriRenderer.php
@@ -58,7 +58,7 @@ public function jsonSerialize(): string;
/**
* Returns the markdown string representation of the anchor tag with the current instance as its href attribute.
*/
- public function toMarkdown(?string $linkTextTemplate = null): string;
+ public function toMarkdownAnchor(?string $linkTextTemplate = null): string;
/**
* Returns the HTML string representation of the anchor tag with the current instance as its href attribute.
@@ -67,25 +67,25 @@ public function toMarkdown(?string $linkTextTemplate = null): string;
*
* @throws DOMException
*/
- public function toAnchorTag(?string $linkTextTemplate = null, iterable $attributes = []): string;
+ public function toHtmlAnchor(?string $linkTextTemplate = null, iterable $attributes = []): string;
/**
* Returns the Link tag content for the current instance.
*
- * @param iterable $attributes an ordered map of key value. you must quote the value if needed
+ * @param iterable $attributes an ordered map of key value
*
* @throws DOMException
*/
- public function toLinkTag(iterable $attributes = []): string;
+ public function toHtmlLink(iterable $attributes = []): string;
/**
* Returns the Link header content for a single item.
*
- * @param iterable $parameters an ordered map of key value. you must quote the value if needed
+ * @param iterable $parameters an ordered map of key value.
*
* @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6
*/
- public function toLinkHeaderValue(iterable $parameters = []): string;
+ public function toHeaderLinkValue(iterable $parameters = []): string;
/**
* Returns the Unix filesystem path. The method returns null for any other scheme except the file scheme.
diff --git a/interfaces/FeatureDetection.php b/interfaces/FeatureDetection.php
index b3e9b09c..205b8050 100644
--- a/interfaces/FeatureDetection.php
+++ b/interfaces/FeatureDetection.php
@@ -17,6 +17,8 @@
use League\Uri\Exceptions\MissingFeature;
use League\Uri\IPv4\Calculator;
+use function extension_loaded;
+
use const PHP_INT_SIZE;
/**
@@ -53,4 +55,14 @@ public static function supportsIPv4Conversion(): void
throw new MissingFeature('A '.Calculator::class.' implementation could not be automatically loaded. To perform IPv4 conversion use a x.64 PHP build or install one of the following extension GMP or BCMath. You can also ship your own implmentation.');
}
}
+
+ public static function supportsDom(): void
+ {
+ static $isSupported = null;
+ $isSupported = $isSupported ?? extension_loaded('dom');
+
+ if (!$isSupported) {
+ throw new MissingFeature('To use a DOM related feature, the DOM extension must be installed in your system.');
+ }
+ }
}
diff --git a/uri/CHANGELOG.md b/uri/CHANGELOG.md
index db2d37ef..1788e38c 100644
--- a/uri/CHANGELOG.md
+++ b/uri/CHANGELOG.md
@@ -14,6 +14,10 @@ All Notable changes to `League\Uri` will be documented in this file
- `Uri` implements the new `League\Uri\Contract\UriInspector` interface
- `Uri` implements the new `League\Uri\Contract\UriRenderer` interface
- `Uri::getUser` returns the encoded user component of the URI an alias for `Uri::getUsername`
+- `Uri::fromMarkdownAnchor`
+- `Uri::fromHtmlAnchor`
+- `Uri::fromHtmlLink`
+- `Uri::fromHeaderLinkValue`
### Fixed
diff --git a/uri/FactoryTest.php b/uri/FactoryTest.php
index 2ec238ee..94d0eb73 100644
--- a/uri/FactoryTest.php
+++ b/uri/FactoryTest.php
@@ -569,4 +569,165 @@ public static function invalidUriWithWhitespaceProvider(): iterable
yield 'uri surrounded by whitespaces' => ['uri' => ' https://a/b?c '];
yield 'uri containing whitespaces' => ['uri' => 'https://a/b ?c'];
}
+
+ #[Test]
+ #[DataProvider('provideAnchorTagHtml')]
+ public function it_parses_uri_string_from_an_anchor_tag(string $html, ?string $baseUri, string $expected): void
+ {
+ self::assertSame($expected, Uri::fromHtmlAnchor($html, $baseUri)->toString());
+ }
+
+ public static function provideAnchorTagHtml(): iterable
+ {
+ yield 'empty string' => [
+ 'html' => '',
+ 'baseUri' => null,
+ 'expected' => '',
+ ];
+
+ yield 'empty string with base URI' => [
+ 'html' => '',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/',
+ ];
+
+ yield 'anchor tag with no base URI' => [
+ 'html' => 'foobar',
+ 'baseUri' => null,
+ 'expected' => '/',
+ ];
+
+ yield 'multiple anchor tag' => [
+ 'html' => 'foobar foobar',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/foobar',
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('provideAnchorTagMarkdown')]
+ public function it_parses_uri_string_from_an_anchor_markdown(string $html, ?string $baseUri, string $expected): void
+ {
+ self::assertSame($expected, Uri::fromMarkdownAnchor($html, $baseUri)->toString());
+ }
+
+ public static function provideAnchorTagMarkdown(): iterable
+ {
+ yield 'empty string' => [
+ 'html' => '[yolo]()',
+ 'baseUri' => null,
+ 'expected' => '',
+ ];
+
+ yield 'empty string with base URI' => [
+ 'html' => '[]()',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/',
+ ];
+
+ yield 'anchor tag with no base URI' => [
+ 'html' => '[yolo](/)',
+ 'baseUri' => null,
+ 'expected' => '/',
+ ];
+
+ yield 'multiple anchor tag' => [
+ 'html' => '[foobar](/foobar) and then later on [foobar](https://example.com/yolo)',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/foobar',
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('provideInvalidMarkdown')]
+ public function it_fails_to_parse_an_invalid_markdown(string $html): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ Uri::fromMarkdownAnchor($html);
+ }
+
+ public static function provideInvalidMarkdown(): iterable
+ {
+ yield 'missing markdown placeholder' => ['html' => 'this is **markdown**'];
+ yield 'invalid markdown placeholder; missing URI part' => ['html' => '[this is an imcomplete] http://example.com markdown'];
+ yield 'invalid markdown placeholder; missing content part' => ['html' => 'this is an imcomplete(http://example.com) markdown'];
+ }
+
+ #[Test]
+ #[DataProvider('provideHeaderLinkValue')]
+ public function it_parses_uri_string_from_an_link_header_value(string $html, ?string $baseUri, string $expected): void
+ {
+ self::assertSame($expected, Uri::fromHeaderLinkValue($html, $baseUri)->toString());
+ }
+
+ public static function provideHeaderLinkValue(): iterable
+ {
+ yield 'empty string' => [
+ 'html' => '<>; rel="previous"',
+ 'baseUri' => null,
+ 'expected' => '',
+ ];
+
+ yield 'empty string with base URI' => [
+ 'html' => '<>; rel="next"',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/',
+ ];
+
+ yield 'URI with base URI' => [
+ 'html' => '; rel="stylesheet"',
+ 'baseUri' => 'https://www.example.com',
+ 'expected' => 'https://www.example.com/style.css',
+ ];
+
+ yield 'multiple anchor tag' => [
+ 'html' => '; rel="stylesheet", ; rel="stylesheet"',
+ 'baseUri' => 'https://example.com/',
+ 'expected' => 'https://example.com/style.css',
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('provideInvalidHeaderLinkValue')]
+ public function it_fails_to_parse_an_invalid_http_header_link_with_invalid_characters(string $html): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ Uri::fromHeaderLinkValue($html);
+ }
+
+ public static function provideInvalidHeaderLinkValue(): iterable
+ {
+ yield 'header value with invalid characters' => ['html' => '; title="stylesheet"'."\r"];
+ yield 'header value with missing URI part' => ['html' => '; rel="stylesheet"'];
+ yield 'header value with missing semicolon' => ['html' => ' title="stylesheet"'];
+ yield 'header value with missing parameters' => ['html' => ''];
+ yield 'header value with missing rel parameter' => ['html' => ' title="stylesheet"'];
+ }
+
+ #[Test]
+ #[DataProvider('provideInvalidUri')]
+ public function it_fails_to_parse_with_new(Stringable|string|null $uri): void
+ {
+ self::assertNull(Uri::tryNew($uri));
+ }
+
+ public static function provideInvalidUri(): iterable
+ {
+ yield 'null value' => ['uri' => null];
+ yield 'invalid URI' => ['uri' => 'http://example.com/ '];
+ }
+
+ #[Test]
+ #[DataProvider('provideInvalidUriForResolution')]
+ public function it_fails_to_parse_with_parse(Stringable|string $uri, Stringable|string|null $baseUri): void
+ {
+ self::assertNull(Uri::parse($uri, $baseUri));
+ }
+
+ public static function provideInvalidUriForResolution(): iterable
+ {
+ yield 'invalid URI' => ['uri' => ':', 'baseUri' => null];
+ yield 'invalid resolution with a non absolute URI' => ['uri' => '', 'baseUri' => '/absolute/path'];
+ }
}
diff --git a/uri/Uri.php b/uri/Uri.php
index b0e34fad..5c6ecb5e 100644
--- a/uri/Uri.php
+++ b/uri/Uri.php
@@ -15,9 +15,11 @@
use Closure;
use Deprecated;
+use Dom\HTMLDocument;
use DOMDocument;
use DOMException;
use finfo;
+use InvalidArgumentException;
use League\Uri\Contracts\Conditionable;
use League\Uri\Contracts\UriComponentInterface;
use League\Uri\Contracts\UriException;
@@ -40,11 +42,11 @@
use TypeError;
use function array_filter;
-use function array_keys;
use function array_map;
use function array_pop;
use function base64_decode;
use function base64_encode;
+use function class_exists;
use function count;
use function end;
use function explode;
@@ -58,7 +60,8 @@
use function is_array;
use function is_bool;
use function is_float;
-use function iterator_to_array;
+use function is_int;
+use function is_string;
use function json_encode;
use function ltrim;
use function preg_match;
@@ -68,6 +71,7 @@
use function restore_error_handler;
use function round;
use function set_error_handler;
+use function sprintf;
use function str_contains;
use function str_repeat;
use function str_replace;
@@ -77,12 +81,16 @@
use function strspn;
use function strtolower;
use function substr;
+use function trim;
use const FILEINFO_MIME;
use const FILEINFO_MIME_TYPE;
use const FILTER_FLAG_IPV4;
use const FILTER_FLAG_IPV6;
+use const FILTER_FLAG_STRIP_HIGH;
+use const FILTER_FLAG_STRIP_LOW;
use const FILTER_NULL_ON_FAILURE;
+use const FILTER_UNSAFE_RAW;
use const FILTER_VALIDATE_BOOLEAN;
use const FILTER_VALIDATE_IP;
use const JSON_PRESERVE_ZERO_FRACTION;
@@ -232,6 +240,8 @@ final class Uri implements Conditionable, UriInterface, UriRenderer, UriInspecto
/** @var array */
private const WHATWG_SPECIAL_SCHEMES = ['ftp' => 1, 'http' => 1, 'https' => 1, 'ws' => 1, 'wss' => 1];
+ private const ABOUT_BLANK = 'about:blank';
+
private readonly ?string $scheme;
private readonly ?string $user;
private readonly ?string $pass;
@@ -658,8 +668,8 @@ public static function fromUnixPath(Stringable|string $path): self
*/
public static function fromWindowsPath(Stringable|string $path): self
{
- $path = (string) $path;
$root = '';
+ $path = (string) $path;
if (1 === preg_match(self::REGEXP_WINDOW_PATH, $path, $matches)) {
$root = substr($matches['root'], 0, -1).':';
$path = substr($path, strlen($root));
@@ -712,6 +722,84 @@ public static function fromServer(array $server): self
return Uri::fromComponents($components);
}
+ public static function fromMarkdownAnchor(Stringable|string $markdown, Stringable|string|null $baseUri = null): self
+ {
+ static $regexp = '/
+ \[(?:[^]]*)] #title attribute
+ \((?[^)]*)\) #href attribute
+ /x';
+ $markdown = trim((string) $markdown);
+ if (1 !== preg_match($regexp, $markdown, $matches)) {
+ throw new SyntaxError('The markdown string `'.$markdown.'` is not valid anchor markdown tag.');
+ }
+
+ if (null !== $baseUri) {
+ $baseUri = (string) $baseUri;
+ }
+
+ return match ($baseUri) {
+ self::ABOUT_BLANK, null => self::new($matches['uri']),
+ default => self::fromBaseUri($matches['uri'], $baseUri),
+ };
+ }
+
+ public static function fromHeaderLinkValue(Stringable|string $headerValue, Stringable|string|null $baseUri = null): self
+ {
+ $headerValue = (string) $headerValue;
+ if (
+ 1 === preg_match("/(?:(?:(?.*?)>(?.*)/';
+ if (1 !== preg_match($regexp, $headerValue, $matches)) {
+ throw new InvalidArgumentException('As per RFC8288, the URI must be defined inside two `<>` characters.');
+ }
+
+ $attributes = [];
+ if (false !== preg_match_all('/;\s*(?\w*)\*?="(?[^"]*)"/', $matches['parameters'], $attrMatches, PREG_SET_ORDER)) {
+ foreach ($attrMatches as $attrMatch) {
+ $attributes[$attrMatch['name']] = $attrMatch['value'];
+ }
+ }
+
+ if (!isset($attributes['rel'])) {
+ throw new SyntaxError('The `rel` attribute must be defined.');
+ }
+
+ if (null !== $baseUri) {
+ $baseUri = (string) $baseUri;
+ }
+
+ return match ($baseUri) {
+ self::ABOUT_BLANK, null => self::new($matches['uri']),
+ default => self::fromBaseUri($matches['uri'], $baseUri),
+ };
+ }
+
+ /**
+ * If the html content contains more than one anchor element, only the first one will be parsed.
+ *
+ * @throws DOMException
+ */
+ public static function fromHtmlAnchor(string $html, Stringable|string|null $baseUri = null): self
+ {
+ return self::parseHtml($html, 'a', 'href', $baseUri);
+ }
+
+ /**
+ * If the html content contains more than one link element, only the first one will be parsed.
+ *
+ * @throws DOMException
+ */
+ public static function fromHtmlLink(string $html, Stringable|string|null $baseUri = null): self
+ {
+ return self::parseHtml($html, 'link', 'href', $baseUri);
+ }
+
/**
* Returns the environment scheme.
*/
@@ -1090,7 +1178,7 @@ public function toDisplayString(): string
/**
* Returns the markdown string representation of the anchor tag with the current instance as its href attribute.
*/
- public function toMarkdown(?string $linkTextTemplate = null): string
+ public function toMarkdownAnchor(?string $linkTextTemplate = null): string
{
return '['.strtr($linkTextTemplate ?? '{uri}', ['{uri}' => $this->toDisplayString()]).']('.$this->toString().')';
}
@@ -1098,86 +1186,92 @@ public function toMarkdown(?string $linkTextTemplate = null): string
/**
* Returns the HTML string representation of the anchor tag with the current instance as its href attribute.
*
- * @param iterable $attributes an ordered map of key value. you must quote the value if needed
+ * @param iterable> $attributes an ordered map of key value. you must quote the value if needed
*
* @throws DOMException
*/
- public function toAnchorTag(?string $linkTextTemplate = null, iterable $attributes = []): string
+ public function toHtmlAnchor(?string $linkTextTemplate = null, iterable $attributes = []): string
{
- $doc = new DOMDocument('1.0', 'utf-8');
- $doc->preserveWhiteSpace = false;
- $doc->formatOutput = true;
- $anchor = $doc->createElement('a');
- $anchor->setAttribute('href', $this->toString());
- foreach ($attributes as $name => $value) {
- if ('href' !== strtolower($name) && null !== $value) {
- $anchor->setAttribute($name, $value);
- }
- }
+ $content = strtr($linkTextTemplate ?? '{uri}', ['{uri}' => $this->toDisplayString()]);
- $anchor->appendChild($doc->createTextNode(strtr($linkTextTemplate ?? '{uri}', ['{uri}' => $this->toDisplayString()])));
- $html = $doc->saveHTML($anchor);
- if (false === $html) {
- throw new DOMException('The anchor tag generation failed.');
- }
-
- return $html;
+ return self::buildHtml($this, 'a', $attributes, $content);
}
/**
* Returns the Link tag content for the current instance.
*
- * @param iterable $attributes an ordered map of key value. you must quote the value if needed
+ * @param iterable> $attributes an ordered map of key value. you must quote the value if needed
*
* @throws DOMException
*/
- public function toLinkTag(iterable $attributes = []): string
+ public function toHtmlLink(iterable $attributes = []): string
{
- $doc = new DOMDocument('1.0', 'utf-8');
- $doc->preserveWhiteSpace = false;
- $doc->formatOutput = true;
- $link = $doc->createElement('link');
- $link->setAttribute('href', $this->toString());
- foreach ($attributes as $name => $value) {
- if ('href' !== strtolower($name) && null !== $value) {
- $link->setAttribute($name, $value);
- }
- }
-
- $html = $doc->saveHTML($link);
- if (false === $html) {
- throw new DOMException('The link generation failed.');
- }
-
- return $html;
+ return self::buildHtml($this, 'link', $attributes, null);
}
/**
* Returns the Link header content for a single item.
*
- * @param iterable $parameters an ordered map of key value. you must quote the value if needed
+ * @param iterable $parameters an ordered map of key value.
*
* @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6
*/
- public function toLinkHeaderValue(iterable $parameters = []): string
+ public function toHeaderLinkValue(iterable $parameters = []): string
{
- $value = '<'.$this->toString().'>';
- if (!is_array($parameters)) {
- $parameters = iterator_to_array($parameters);
+ $attributes = [];
+ foreach ($parameters as $name => $val) {
+ if (null !== $val && false !== $val) {
+ $attributes[] = $this->formatHeaderValueParameter($name, $val);
+ }
}
- if ([] === $parameters) {
+ $value = '<'.$this->toString().'>';
+ if ([] === $attributes) {
return $value;
}
- $formatter = static fn (string|int|float|bool $member, string $offset): string => match (true) {
- true === $member => ';'.$offset,
- false === $member => ';'.$offset.'=?0',
- is_float($member) => ';'.$offset.'='.json_encode(round($member, 3, PHP_ROUND_HALF_EVEN), JSON_PRESERVE_ZERO_FRACTION),
- default => ';'.$offset.'='.$member,
+ return $value.implode('', $attributes);
+ }
+
+ private function formatHeaderValueParameter(string $name, string|int|float|bool $value): string
+ {
+ $name = strtolower($name);
+
+ return '; '.$name.match (true) {
+ 1 !== preg_match('/^([a-z*][a-z\d.*_-]*)$/i', $name) => throw new InvalidArgumentException('The parameter name `'.$name.'` contains invalid characters.'),
+ true === $value => '',
+ false === $value => '=?0',
+ is_float($value) => '="'.json_encode(round($value, 3, PHP_ROUND_HALF_EVEN), JSON_PRESERVE_ZERO_FRACTION).'"',
+ is_int($value) => '="'.$value.'"',
+ default => $this->formatHeaderValueStringParameter($name, $value),
};
+ }
+
+ private function formatHeaderValueStringParameter(string $name, string $value): string
+ {
+ if (
+ 1 === preg_match("/(?:(?:(? strtolower(rawurlencode($matches[0])),
+ $value
+ ));
}
/**
@@ -1838,6 +1932,90 @@ public function __unserialize(array $data): void
$this->origin = $this->setOrigin();
}
+ private static function parseHtml(
+ Stringable|string $content,
+ string $tagName,
+ string $attributeName,
+ Stringable|string|null $baseUri = null
+ ): self {
+ FeatureDetection::supportsDom();
+
+ set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true);
+ $result = true;
+ if (class_exists(HTMLDocument::class)) {
+ $dom = HTMLDocument::createFromString((string) $content);
+ } else {
+ $dom = new DOMDocument();
+ $result = $dom->loadHTML((string) $content);
+ }
+ restore_error_handler();
+ if (false === $result) {
+ throw new DOMException('The content could not be parsed as a valid HTML content.');
+ }
+
+ $tag = $dom->getElementsByTagName($tagName)->item(0);
+ if (null === $tag) {
+ throw new DOMException('No `'.$tagName.'` element was found in the content.');
+ }
+
+ $uri = $tag->getAttribute($attributeName);
+
+ if (null !== $baseUri) {
+ $baseUri = (string) $baseUri;
+ }
+
+ return match (true) {
+ null !== $baseUri && self::ABOUT_BLANK !== $baseUri => self::fromBaseUri($uri, $baseUri),
+ null !== $dom->documentURI && self::ABOUT_BLANK !== $dom->documentURI => self::fromBaseUri($uri, $dom->documentURI),
+ default => self::new($uri),
+ };
+ }
+
+ /**
+ * @param iterable> $attributes
+ *
+ * @throws DOMException
+ */
+ private static function buildHtml(self $uri, string $tagName, iterable $attributes, ?string $content): string
+ {
+ FeatureDetection::supportsDom();
+
+ $doc = class_exists(HTMLDocument::class) ? HTMLDocument::createEmpty() : new DOMDocument(encoding:'utf-8');
+ $element = $doc->createElement($tagName);
+ $element->setAttribute('href', $uri->toString());
+ if (null !== $content) {
+ $element->appendChild($doc->createTextNode($content));
+ }
+
+ foreach ($attributes as $name => $value) {
+ if ('href' === strtolower($name) || null === $value) {
+ continue;
+ }
+
+ if (is_array($value)) {
+ $value = implode(' ', $value);
+ }
+
+ if (!is_string($value)) {
+ throw new TypeError('The attribute `'.$name.'` contains an invalid value.');
+ }
+
+ $value = trim($value);
+ if ('' === $value) {
+ continue;
+ }
+
+ $element->setAttribute($name, $value);
+ }
+
+ $html = $doc->saveHTML($element);
+ if (false === $html) {
+ throw new DOMException('The HTML generation failed.');
+ }
+
+ return $html;
+ }
+
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
diff --git a/uri/UriTest.php b/uri/UriTest.php
index 93df2bf6..365941dd 100644
--- a/uri/UriTest.php
+++ b/uri/UriTest.php
@@ -11,7 +11,9 @@
namespace League\Uri;
+use DOMException;
use GuzzleHttp\Psr7\Utils;
+use InvalidArgumentException;
use League\Uri\Components\HierarchicalPath;
use League\Uri\Components\Port;
use League\Uri\Exceptions\SyntaxError;
@@ -985,7 +987,7 @@ public static function providesUriToDisplay(): iterable
#[DataProvider('providesUriToMarkdown')]
public function it_will_generate_the_markdown_code_for_the_instance(string $uri, ?string $content, string $expected): void
{
- self::assertSame($expected, Uri::new($uri)->toMarkdown($content));
+ self::assertSame($expected, Uri::new($uri)->toMarkdownAnchor($content));
}
public static function providesUriToMarkdown(): iterable
@@ -1010,13 +1012,13 @@ public static function providesUriToMarkdown(): iterable
}
#[Test]
- #[DataProvider('providesUriToHTML')]
- public function it_will_generate_the_html_code_for_the_instance(string $uri, ?string $content, array $parameters, string $expected): void
+ #[DataProvider('providesUriToAnchorTagHTML')]
+ public function it_will_generate_the_html_anchor_tag_code_for_the_instance(string $uri, ?string $content, array $parameters, string $expected): void
{
- self::assertSame($expected, Uri::new($uri)->toAnchorTag($content, $parameters));
+ self::assertSame($expected, Uri::new($uri)->toHtmlAnchor($content, $parameters));
}
- public static function providesUriToHTML(): iterable
+ public static function providesUriToAnchorTagHTML(): iterable
{
yield 'empty string' => [
'uri' => '',
@@ -1043,7 +1045,7 @@ public static function providesUriToHTML(): iterable
'uri' => 'http://Bébé.be',
'content' => null,
'parameters' => [
- 'class' => 'foo bar',
+ 'class' => ['foo', 'bar'],
'target' => null,
],
'expected' => 'http://bébé.be',
@@ -1070,6 +1072,133 @@ public static function providesUriToHTML(): iterable
];
}
+ #[Test]
+ #[DataProvider('providesUriToLinkTagHTML')]
+ public function it_will_generate_the_html_link_tag_code_for_the_instance(string $uri, array $parameters, string $expected): void
+ {
+ self::assertSame($expected, Uri::new($uri)->toHtmlLink($parameters));
+ }
+
+ public static function providesUriToLinkTagHTML(): iterable
+ {
+ yield 'empty string' => [
+ 'uri' => '',
+ 'parameters' => [],
+ 'expected' => '',
+ ];
+
+ yield 'URI without content' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [],
+ 'expected' => '',
+ ];
+
+ yield 'URI without content and with class' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [
+ 'class' => ['foo', 'bar'],
+ 'target' => null,
+ ],
+ 'expected' => '',
+ ];
+
+ yield 'URI without content and with target' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [
+ 'class' => null,
+ 'rel' => 'stylesheet',
+ ],
+ 'expected' => '',
+ ];
+ }
+
+ #[Test]
+ public function it_will_fail_to_generate_an_anchor_tag_html_for_the_instance(): void
+ {
+ $this->expectException(DOMException::class);
+ Uri::new('https://example.com')->toHtmlAnchor(attributes: ["bébé\r\n" => 'yes']);
+ }
+
+ #[Test]
+ #[DataProvider('providesUriToLinkHeaderValue')]
+ public function it_will_generate_the_link_header_value_for_the_instance(string $uri, array $parameters, string $expected): void
+ {
+ self::assertSame($expected, Uri::new($uri)->toHeaderLinkValue($parameters));
+ }
+
+ public static function providesUriToLinkHeaderValue(): iterable
+ {
+ yield 'empty string' => [
+ 'uri' => '',
+ 'parameters' => [],
+ 'expected' => '<>',
+ ];
+
+ yield 'URI without content' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [],
+ 'expected' => '',
+ ];
+
+ yield 'URI without content and with class' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [
+ 'rel' => 'stylesheet',
+ 'target' => null,
+ ],
+ 'expected' => '; rel="stylesheet"',
+ ];
+
+ yield 'URI without content and with target' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [
+ 'title' => null,
+ 'rel' => 'stylesheet',
+ ],
+ 'expected' => '; rel="stylesheet"',
+ ];
+
+ yield 'URI without title with UTF-8 character' => [
+ 'uri' => 'http://Bébé.be',
+ 'parameters' => [
+ 'title' => 'je suis un bébé',
+ 'rel' => 'previous',
+ ],
+ 'expected' => '; title="je suis un bb"; title*=utf-8\'\'je suis un b%c3%a9b%c3%a9; rel="previous"',
+ ];
+ }
+
+ #[Test]
+ #[DataProvider('providesUriToLinkInvalidHeaderValue')]
+ public function it_will_fail_to_generate_a_link_header_value_for_the_instance(string $uri, array $parameters): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ Uri::new($uri)->toHeaderLinkValue($parameters);
+ }
+
+ public static function providesUriToLinkInvalidHeaderValue(): iterable
+ {
+ yield 'the parameter name contains invalid character' => [
+ 'uri' => 'http://www.example.com/',
+ 'parameters' => ["bébé\0" => 'yes'],
+ ];
+
+ yield 'the parameter name contains space' => [
+ 'uri' => 'http://www.example.com/',
+ 'parameters' => ['bébé ' => 'yes'],
+ ];
+
+ yield 'the parameter value contains invalid character (1)' => [
+ 'uri' => 'http://www.example.com/',
+ 'parameters' => ['foobar' => "yes\r\n"],
+ ];
+
+ yield 'the parameter value contains invalid character (2)' => [
+ 'uri' => 'http://www.example.com/',
+ 'parameters' => ['foobar' => "yes\0"],
+ ];
+ }
+
#[Test]
public function it_can_update_the_user_component(): void
{