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 {