├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── LICENSE.md ├── composer.json ├── config └── autolink.php ├── pint.json └── src ├── Autolink.php ├── AutolinkServiceProvider.php ├── Contracts ├── Element.php ├── Filter.php └── Parser.php ├── Cursor.php ├── Elements ├── BaseElement.php ├── EmailElement.php └── UrlElement.php ├── Facades └── Autolink.php ├── Filters ├── LimitLengthFilter.php └── TrimFilter.php ├── HtmlRenderer.php ├── Parser.php ├── Parsers ├── AbstractParser.php ├── AbstractUrlParser.php ├── EmailParser.php ├── UrlParser.php └── WwwParser.php └── helpers.php /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Fix Code Style 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.4] 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php }} 22 | extensions: json, dom, curl, libxml, mbstring 23 | coverage: none 24 | 25 | - name: Install Pint 26 | run: composer global require laravel/pint 27 | 28 | - name: Run Pint 29 | run: pint 30 | 31 | - name: Commit linted files 32 | uses: stefanzweifel/git-auto-commit-action@v5 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.2, 8.3, 8.4] 13 | 14 | name: PHP ${{ matrix.php }} 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite 25 | coverage: none 26 | 27 | - name: Install Composer dependencies 28 | run: composer install --prefer-dist --no-interaction --no-progress 29 | 30 | - name: Run PHPUnit 31 | run: vendor/bin/phpunit 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Marek Szymczuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osiemsiedem/laravel-autolink", 3 | "description": "A Laravel package for converting URLs in a given string of text into clickable links.", 4 | "keywords": [ 5 | "laravel", 6 | "autolink", 7 | "html", 8 | "email", 9 | "url", 10 | "www" 11 | ], 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Marek Szymczuk", 16 | "email": "marek@osiemsiedem.com", 17 | "homepage": "http://osiemsiedem.com" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "illuminate/support": "^12.0", 23 | "symfony/polyfill-mbstring": "^1.31", 24 | "spatie/laravel-html": "^3.11" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^11.5.3", 28 | "mockery/mockery": "^1.6", 29 | "laravel/pint": "^1.21" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "OsiemSiedem\\Autolink\\": "src/" 34 | }, 35 | "files": [ 36 | "src/helpers.php" 37 | ] 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "OsiemSiedem\\Tests\\Autolink\\": "tests/" 42 | } 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "OsiemSiedem\\Autolink\\AutolinkServiceProvider" 48 | ], 49 | "aliases": { 50 | "Autolink": "OsiemSiedem\\Autolink\\Facades\\Autolink" 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/autolink.php: -------------------------------------------------------------------------------- 1 | [ 13 | 'a', 14 | 'code', 15 | 'kbd', 16 | 'pre', 17 | 'script', 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Filters 23 | |-------------------------------------------------------------------------- 24 | | 25 | */ 26 | 'filters' => [ 27 | \OsiemSiedem\Autolink\Filters\TrimFilter::class, 28 | \OsiemSiedem\Autolink\Filters\LimitLengthFilter::class, 29 | ], 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Parsers 34 | |-------------------------------------------------------------------------- 35 | | 36 | */ 37 | 'parsers' => [ 38 | \OsiemSiedem\Autolink\Parsers\UrlParser::class, 39 | \OsiemSiedem\Autolink\Parsers\WwwParser::class, 40 | \OsiemSiedem\Autolink\Parsers\EmailParser::class, 41 | ], 42 | ]; 43 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel" 3 | } 4 | -------------------------------------------------------------------------------- /src/Autolink.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 21 | $this->renderer = $renderer; 22 | } 23 | 24 | /** 25 | * Convert the URLs into clickable links. 26 | */ 27 | public function convert(string $text, ?callable $callback = null): HtmlString 28 | { 29 | $elements = $this->parse($text); 30 | 31 | return $this->render($text, $elements, $callback); 32 | } 33 | 34 | /** 35 | * Parse the text. 36 | * 37 | * @return \OsiemSiedem\Autolink\Contracts\Element[] 38 | */ 39 | public function parse(string $text): array 40 | { 41 | return $this->parser->parse($text); 42 | } 43 | 44 | /** 45 | * Render the elements. 46 | */ 47 | public function render(string $text, array $elements, ?callable $callback = null): HtmlString 48 | { 49 | return $this->renderer->render($text, $elements, $callback); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/AutolinkServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 18 | __DIR__.'/../config/autolink.php', 'autolink' 19 | ); 20 | 21 | $this->publishes([ 22 | __DIR__.'/../config/autolink.php' => config_path('autolink.php'), 23 | ], 'config'); 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function register(): void 30 | { 31 | $this->app->singleton('osiemsiedem.autolink.parser', function ($app) { 32 | $config = $app['config']->get('autolink'); 33 | 34 | $parser = new Parser; 35 | 36 | $parser->setIgnoredTags($config['ignored_tags']); 37 | 38 | foreach ($config['parsers'] as $elementParser) { 39 | $parser->addElementParser(new $elementParser); 40 | } 41 | 42 | return $parser; 43 | }); 44 | 45 | $this->app->singleton('osiemsiedem.autolink.renderer', function ($app) { 46 | $renderer = new HtmlRenderer; 47 | 48 | foreach ($app['config']->get('autolink.filters') as $filter) { 49 | $renderer->addFilter(new $filter); 50 | } 51 | 52 | return $renderer; 53 | }); 54 | 55 | $this->app->singleton('osiemsiedem.autolink', function ($app) { 56 | return new Autolink($app['osiemsiedem.autolink.parser'], $app['osiemsiedem.autolink.renderer']); 57 | }); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function provides() 64 | { 65 | return [ 66 | 'osiemsiedem.autolink.parser', 67 | 'osiemsiedem.autolink.renderer', 68 | 'osiemsiedem.autolink', 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Contracts/Element.php: -------------------------------------------------------------------------------- 1 | text = $text; 31 | $this->encoding = $encoding; 32 | $this->length = mb_strlen($text, $encoding); 33 | } 34 | 35 | /** 36 | * Get the current character. 37 | */ 38 | public function current(): ?string 39 | { 40 | return $this->getCharacter(); 41 | } 42 | 43 | /** 44 | * Get the key of the current element. 45 | */ 46 | public function key(): int 47 | { 48 | return $this->getPosition(); 49 | } 50 | 51 | /** 52 | * Move forward to the next character. 53 | */ 54 | public function next(int $offset = 1): void 55 | { 56 | $this->position = $this->position + $offset; 57 | } 58 | 59 | /** 60 | * Move backward to the previous character. 61 | */ 62 | public function prev(int $offset = 1): void 63 | { 64 | $this->position = $this->position - $offset; 65 | } 66 | 67 | /** 68 | * Rewind to the first character. 69 | */ 70 | public function rewind(): void 71 | { 72 | $this->position = 0; 73 | } 74 | 75 | /** 76 | * Check if the current position is valid. 77 | */ 78 | public function valid(): bool 79 | { 80 | return $this->position >= 0 && $this->position < $this->length; 81 | } 82 | 83 | /** 84 | * Get the state. 85 | */ 86 | public function getState(): array 87 | { 88 | return ['position' => $this->position]; 89 | } 90 | 91 | /** 92 | * Save the state. 93 | */ 94 | public function setState(array $state): void 95 | { 96 | $this->position = Arr::get($state, 'position', 0); 97 | } 98 | 99 | /** 100 | * Check if the given pattern is matched. 101 | */ 102 | public function match(string $pattern): bool 103 | { 104 | $text = $this->getText($this->getPosition()); 105 | 106 | return preg_match($pattern, $text) > 0; 107 | } 108 | 109 | /** 110 | * Get the character. 111 | */ 112 | public function getCharacter(?int $position = null): ?string 113 | { 114 | if ($position === null) { 115 | $position = $this->getPosition(); 116 | } 117 | 118 | if (isset($this->cache[$position])) { 119 | return $this->cache[$position]; 120 | } 121 | 122 | if ($position < 0 || $position >= $this->getLength()) { 123 | return null; 124 | } 125 | 126 | return $this->cache[$position] = $this->getText($position, 1); 127 | } 128 | 129 | /** 130 | * Get the text. 131 | */ 132 | public function getText(?int $start = null, ?int $length = null): string 133 | { 134 | if (is_null($start)) { 135 | $start = 0; 136 | } 137 | 138 | return mb_substr($this->text, $start, $length, $this->getEncoding()); 139 | } 140 | 141 | /** 142 | * Get the encoding. 143 | */ 144 | public function getEncoding(): string 145 | { 146 | return $this->encoding; 147 | } 148 | 149 | /** 150 | * Get the length. 151 | */ 152 | public function getLength(): int 153 | { 154 | return $this->length; 155 | } 156 | 157 | /** 158 | * Get the position. 159 | */ 160 | public function getPosition(): int 161 | { 162 | return $this->position; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Elements/BaseElement.php: -------------------------------------------------------------------------------- 1 | title = $title; 45 | $this->url = $url; 46 | $this->start = $start; 47 | $this->end = $end; 48 | $this->attributes = (new Attributes)->setAttributes($attributes); 49 | } 50 | 51 | /** 52 | * Get the title. 53 | */ 54 | public function getTitle(): string 55 | { 56 | return $this->title; 57 | } 58 | 59 | /** 60 | * Set the title. 61 | * 62 | * @return $this 63 | */ 64 | public function setTitle(string $title): self 65 | { 66 | $this->title = $title; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Get the url. 73 | */ 74 | public function getUrl(): string 75 | { 76 | return $this->url; 77 | } 78 | 79 | /** 80 | * Set the url. 81 | * 82 | * @return $this 83 | */ 84 | public function setUrl(string $url): self 85 | { 86 | $this->url = $url; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Get the start position. 93 | */ 94 | public function getStart(): int 95 | { 96 | return $this->start; 97 | } 98 | 99 | /** 100 | * Get the end position. 101 | */ 102 | public function getEnd(): int 103 | { 104 | return $this->end; 105 | } 106 | 107 | /** 108 | * Get the attributes. 109 | */ 110 | public function getAttributes(): Attributes 111 | { 112 | return $this->attributes; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Elements/EmailElement.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 23 | $this->end = $end; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function filter(Element $element): Element 30 | { 31 | $title = $element->getTitle(); 32 | 33 | $title = Str::limit($title, $this->limit, $this->end); 34 | 35 | $element->setTitle($title); 36 | 37 | return $element; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Filters/TrimFilter.php: -------------------------------------------------------------------------------- 1 | getTitle(); 23 | 24 | $title = preg_replace('#^\w+://#i', '', $title); 25 | 26 | $title = preg_replace('#^(?:www[0-9]*\.)?#i', '', $title); 27 | 28 | $title = rtrim($title, '/'); 29 | 30 | $element->setTitle($title); 31 | 32 | return $element; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/HtmlRenderer.php: -------------------------------------------------------------------------------- 1 | filters[] = $filter; 21 | 22 | return $this; 23 | } 24 | 25 | /** 26 | * Render the elements in the given text. 27 | * 28 | * @param \OsiemSiedem\Autolink\Elements\BaseElement[] $elements 29 | */ 30 | public function render(string $text, array $elements, ?callable $callback = null): HtmlString 31 | { 32 | for ($i = count($elements) - 1; $i >= 0; $i--) { 33 | $start = $elements[$i]->getStart(); 34 | $end = $elements[$i]->getEnd(); 35 | 36 | foreach ($this->filters as $filter) { 37 | $elements[$i] = $filter->filter($elements[$i]); 38 | } 39 | 40 | if (! is_null($callback)) { 41 | $elements[$i] = $callback($elements[$i]); 42 | } 43 | 44 | $link = A::create() 45 | ->href($elements[$i]->getUrl()) 46 | ->text($elements[$i]->getTitle()) 47 | ->attributes($elements[$i]->getAttributes()->toArray()) 48 | ->toHtml(); 49 | 50 | $text = mb_substr($text, 0, $start) 51 | .$link 52 | .mb_substr($text, $end, mb_strlen($text) - $end); 53 | } 54 | 55 | return new HtmlString($text); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected array $elementParsers = []; 21 | 22 | /** 23 | * Add a new parser. 24 | */ 25 | public function addElementParser(ElementParser $parser): self 26 | { 27 | foreach ($parser->getCharacters() as $character) { 28 | $this->elementParsers[$character][] = $parser; 29 | } 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * Set the ignored tags. 36 | * 37 | * @param string[] $ignored 38 | */ 39 | public function setIgnoredTags(array $ignored): self 40 | { 41 | $this->ignoredTags = $ignored; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Parse the text. 48 | * 49 | * @return \OsiemSiedem\Autolink\Contracts\Element[] 50 | */ 51 | public function parse(string $text): array 52 | { 53 | $cursor = new Cursor($text); 54 | 55 | $elements = []; 56 | 57 | foreach ($cursor as $character) { 58 | if ($character === '<') { 59 | foreach ($this->ignoredTags as $ignoredTag) { 60 | $length = strlen($ignoredTag) + 1; 61 | 62 | $tag = $cursor->getText($cursor->getPosition(), $length); 63 | 64 | if ($tag === "<{$ignoredTag}") { 65 | $cursor->next($length); 66 | 67 | while ($cursor->valid()) { 68 | while ($cursor->valid() && $cursor->getCharacter() !== '<') { 69 | $cursor->next(); 70 | } 71 | 72 | if ($cursor->getPosition() === $cursor->getLength()) { 73 | break 2; 74 | } 75 | 76 | $tag = $cursor->getText($cursor->getPosition(), strlen($ignoredTag) + 2); 77 | 78 | if ($tag === "next(); 83 | } 84 | 85 | break; 86 | } 87 | } 88 | 89 | while ($cursor->valid() && $cursor->getCharacter() !== '>') { 90 | $cursor->next(); 91 | } 92 | 93 | continue; 94 | } 95 | 96 | /** @var \OsiemSiedem\Autolink\Contracts\Parser[]|null $parsers */ 97 | $parsers = Arr::get($this->elementParsers, $character); 98 | 99 | if (is_null($parsers)) { 100 | continue; 101 | } 102 | 103 | foreach ($parsers as $parser) { 104 | if ($element = $parser->parse($cursor)) { 105 | $elements[] = $element; 106 | 107 | break; 108 | } 109 | } 110 | } 111 | 112 | return $elements; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Parsers/AbstractParser.php: -------------------------------------------------------------------------------- 1 | getCharacter($i); 22 | 23 | if ($character === '<') { 24 | $end = $i; 25 | 26 | break; 27 | } 28 | } 29 | 30 | while ($end > $start) { 31 | $character = $cursor->getCharacter($end - 1); 32 | 33 | if (strpos('?!.,:', $character) !== false) { 34 | $end--; 35 | } elseif ($character === ';') { 36 | $newEnd = $end - 2; 37 | 38 | while ($newEnd > 0 && ctype_alnum((string) $cursor->getCharacter($newEnd))) { 39 | $newEnd--; 40 | } 41 | 42 | if ($newEnd < $end - 2) { 43 | if ($newEnd > 0 && $cursor->getCharacter($newEnd) === '#') { 44 | $newEnd--; 45 | } 46 | 47 | if ($cursor->getCharacter($newEnd) === '&') { 48 | $end = $newEnd; 49 | 50 | continue; 51 | } 52 | } 53 | 54 | $end--; 55 | 56 | continue; 57 | } 58 | 59 | break; 60 | } 61 | 62 | if ($end === $start) { 63 | return null; 64 | } 65 | 66 | $closeParenthesis = $cursor->getCharacter($end - 1); 67 | 68 | if ($openParenthesis = $this->getMatchingParenthesis($closeParenthesis)) { 69 | $opening = $closing = 0; 70 | $i = $start; 71 | 72 | while ($i < $end) { 73 | $character = $cursor->getCharacter($i); 74 | 75 | if ($character === $openParenthesis) { 76 | $opening++; 77 | } elseif ($character === $closeParenthesis) { 78 | $closing++; 79 | } 80 | 81 | $i++; 82 | } 83 | 84 | if ($openParenthesis === $closeParenthesis) { 85 | if ($opening > 0) { 86 | $end--; 87 | } 88 | } else { 89 | if ($closing > $opening) { 90 | $end--; 91 | } 92 | } 93 | } 94 | 95 | return [ 96 | 'start' => $start, 97 | 'end' => $end, 98 | ]; 99 | } 100 | 101 | /** 102 | * Trim the delimeters. 103 | * 104 | * @return array{start: int, end: int}|null 105 | */ 106 | protected function trimMoreDelimeters(Cursor $cursor, int $start, int $end): ?array 107 | { 108 | for ($iterations = 0; $iterations < 5; $iterations++) { 109 | $prevEnd = $end; 110 | 111 | if ($position = $this->trimDelimeters($cursor, $start, $end)) { 112 | $start = $position['start']; 113 | $end = $position['end']; 114 | } else { 115 | return null; 116 | } 117 | 118 | if ($prevEnd === $end) { 119 | break; 120 | } 121 | } 122 | 123 | return [ 124 | 'start' => $start, 125 | 'end' => $end, 126 | ]; 127 | } 128 | 129 | /** 130 | * Get the matching parenthesis. 131 | */ 132 | protected function getMatchingParenthesis(string $parenthesis): ?string 133 | { 134 | return Arr::get([ 135 | '"' => '"', 136 | "'" => "'", 137 | ')' => '(', 138 | ']' => '[', 139 | '}' => '{', 140 | ')' => '(', 141 | '】' => '【', 142 | '』' => '『', 143 | '」' => '「', 144 | '》' => '《', 145 | '〉' => '〈', 146 | ], $parenthesis); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Parsers/AbstractUrlParser.php: -------------------------------------------------------------------------------- 1 | getCharacter($start))) { 17 | return false; 18 | } 19 | 20 | $dot = 0; 21 | 22 | for ($i = $start + 3, $j = $cursor->getLength() - 1; $i < $j; $i++) { 23 | $character = (string) $cursor->getCharacter($i); 24 | 25 | if ($character === '.') { 26 | $dot++; 27 | } elseif (! ctype_alnum($character) && $character !== '-') { 28 | break; 29 | } 30 | } 31 | 32 | return $dot >= ($allowShort ? 0 : 2); 33 | } 34 | 35 | /** 36 | * Check for whitespace character. 37 | */ 38 | protected function isWhitespace(string $character): bool 39 | { 40 | $ord = mb_ord($character); 41 | 42 | if ($ord >= 9 && $ord <= 13) { 43 | return true; 44 | } 45 | 46 | if (in_array($ord, [32, 160, 5760, 8239, 8287, 12288])) { 47 | return true; 48 | } 49 | 50 | return $ord >= 8192 && $ord <= 8202; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Parsers/EmailParser.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function getCharacters(): array 19 | { 20 | return ['@']; 21 | } 22 | 23 | /** 24 | * Parse the text. 25 | */ 26 | public function parse(Cursor $cursor): ?Element 27 | { 28 | $state = $cursor->getState(); 29 | 30 | // 31 | // Local-part 32 | // 33 | $start = $cursor->getPosition(); 34 | 35 | $cursor->prev(); 36 | 37 | while ($cursor->valid()) { 38 | $character = (string) $cursor->getCharacter(); 39 | 40 | if (ctype_alnum($character) || strpos('.+-_%', $character) !== false) { 41 | $start = $cursor->getPosition(); 42 | 43 | $cursor->prev(); 44 | 45 | continue; 46 | } 47 | 48 | break; 49 | } 50 | 51 | $cursor->setState($state); 52 | 53 | if ($start === $cursor->getPosition()) { 54 | return null; 55 | } 56 | 57 | // 58 | // Domain 59 | // 60 | $end = $cursor->getPosition(); 61 | 62 | $at = $dot = 0; 63 | 64 | while ($cursor->valid()) { 65 | $character = (string) $cursor->getCharacter(); 66 | 67 | if (ctype_alnum($character)) { 68 | $cursor->next(); 69 | 70 | $end = $cursor->getPosition(); 71 | 72 | continue; 73 | } 74 | 75 | if ($character === '@') { 76 | $at++; 77 | } elseif ($character === '.' && $end < $cursor->getLength() - 1) { 78 | $dot++; 79 | } elseif ($character !== '-' && $character !== '_') { 80 | break; 81 | } 82 | 83 | $cursor->next(); 84 | 85 | $end = $cursor->getPosition(); 86 | } 87 | 88 | if (($end - $start) < 5 || $at !== 1 || $dot === 0 || ($dot === 1 && $cursor->getCharacter($end - 1) === '.')) { 89 | $cursor->setState($state); 90 | 91 | return null; 92 | } 93 | 94 | $position = $this->trimDelimeters($cursor, $start, $end); 95 | 96 | $title = $cursor->getText($position['start'], $position['end'] - $position['start']); 97 | 98 | $url = "mailto:{$title}"; 99 | 100 | return new EmailElement($title, $url, $position['start'], $position['end']); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Parsers/UrlParser.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $protocols = ['http://', 'https://']; 20 | 21 | /** 22 | * Get the characters. 23 | * 24 | * @return array 25 | */ 26 | public function getCharacters(): array 27 | { 28 | return [':']; 29 | } 30 | 31 | /** 32 | * Parse the text. 33 | */ 34 | public function parse(Cursor $cursor): ?Element 35 | { 36 | $state = $cursor->getState(); 37 | 38 | $start = $cursor->getPosition(); 39 | 40 | if ($cursor->getText($start, 3) !== '://') { 41 | return null; 42 | } 43 | 44 | if (! $this->validateDomain($cursor, $start + 3, $this->allowShort)) { 45 | return null; 46 | } 47 | 48 | while ($cursor->valid()) { 49 | $character = $cursor->getCharacter(); 50 | 51 | if ($this->isWhitespace($character)) { 52 | break; 53 | } 54 | 55 | $cursor->next(); 56 | } 57 | 58 | $end = $cursor->getPosition(); 59 | 60 | while ($start > 0) { 61 | if (ctype_alpha((string) $cursor->getCharacter($start - 1))) { 62 | $start--; 63 | 64 | continue; 65 | } 66 | 67 | break; 68 | } 69 | 70 | if (! $this->validateProtocol($cursor, $start)) { 71 | return null; 72 | } 73 | 74 | $position = $this->trimMoreDelimeters($cursor, $start, $end); 75 | 76 | $state['position'] = $position['end']; 77 | $cursor->setState($state); 78 | 79 | $url = $title = $cursor->getText($position['start'], $position['end'] - $position['start']); 80 | 81 | return new UrlElement($title, $url, $position['start'], $position['end']); 82 | } 83 | 84 | /** 85 | * Validate the protocol. 86 | */ 87 | protected function validateProtocol(Cursor $cursor, int $start): bool 88 | { 89 | $text = strtolower($cursor->getText($start, 8)); 90 | 91 | return Str::startsWith($text, $this->protocols); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Parsers/WwwParser.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getCharacters(): array 21 | { 22 | return ['w', 'W']; 23 | } 24 | 25 | /** 26 | * Parse the text. 27 | */ 28 | public function parse(Cursor $cursor): ?Element 29 | { 30 | $start = $cursor->getPosition(); 31 | 32 | if ($cursor->getLength() - $start < 8) { 33 | return null; 34 | } 35 | 36 | if (strtolower($cursor->getText($start, 4)) !== 'www.') { 37 | return null; 38 | } 39 | 40 | if (! $this->validateDomain($cursor, $start, $this->allowShort)) { 41 | return null; 42 | } 43 | 44 | $boundary = $cursor->getCharacter($start - 1); 45 | 46 | if (! is_null($boundary) && ! ctype_space($boundary) && ! ctype_punct($boundary)) { 47 | return null; 48 | } 49 | 50 | while ($cursor->valid()) { 51 | $character = $cursor->getCharacter(); 52 | 53 | if ($this->isWhitespace($character)) { 54 | break; 55 | } 56 | 57 | $cursor->next(); 58 | } 59 | 60 | $end = $cursor->getPosition(); 61 | 62 | $position = $this->trimMoreDelimeters($cursor, $start, $end); 63 | 64 | $title = $cursor->getText($position['start'], $position['end'] - $position['start']); 65 | 66 | return new UrlElement($title, "http://{$title}", $position['start'], $position['end']); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 |