From 6c059c04a5ce67efdb40a71234e658c76ef81cea Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 20 Oct 2026 01:11:10 +0100 Subject: [PATCH] Link URLs in commits and replies, as the web does A URL or bare domain in a commit or reply is now a link in Commit Blue, found with the web's rules from linkified-text.tsx and the server's Urls.php. It shows shortened the way the web shows it. A dev/null commit shows as its author and short hash and opens in the app. A GitHub repository, issue or pull request shows as GitHub writes it. Everything else opens in the in-app browser. The web puts a dev/null or GitHub icon before those named links. Text runs can't hold an icon, so the app leaves it out. URLs are split out before mentions and the wordmark, as on the web, so a /dev/null inside a URL now stays part of the link. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/BodyLink.php | 165 ++++++++++++++++++ app/Devnull/Data/BodyRun.php | 7 +- app/Devnull/Data/Commit.php | 21 +++ app/Devnull/Data/CommitBody.php | 40 ++++- .../Concerns/ShowsCommits.php | 17 ++ .../native/partials/commit-body.blade.php | 10 ++ tests/Feature/Screens/LogTest.php | 33 ++++ tests/Pest.php | 21 ++- tests/Unit/Devnull/CommitBodyTest.php | 132 +++++++++++++- 9 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 app/Devnull/Data/BodyLink.php diff --git a/app/Devnull/Data/BodyLink.php b/app/Devnull/Data/BodyLink.php new file mode 100644 index 0000000..9e8b732 --- /dev/null +++ b/app/Devnull/Data/BodyLink.php @@ -0,0 +1,165 @@ + + */ + public static function in(string $prose): array + { + preg_match_all(self::pattern(), $prose, $matches, PREG_OFFSET_CAPTURE); + + $links = []; + + foreach ($matches[0] as [$candidate, $byte]) { + $written = self::withoutTrailingPunctuation($candidate); + $href = preg_match('/^(https?)(:\/\/.*)$/is', $written, $scheme) === 1 ? strtolower($scheme[1]).$scheme[2] : 'https://'.$written; + + if (str_ends_with($href, '://') || mb_strlen($href) > self::MaxLength) { + continue; + } + + $links[] = [mb_strlen(substr($prose, 0, $byte)), mb_strlen($written), self::to($written, $href)]; + } + + return $links; + } + + /** + * Whether it shows as a name and a short hash or number rather than as a URL. + */ + public function isNamed(): bool + { + return $this->prefix !== ''; + } + + private static function to(string $written, string $href): self + { + if (preg_match(self::CommitLink, $written, $commit) === 1) { + $id = strtolower($commit[2]); + + return new self($href, substr(str_replace('-', '', $id), -7), $commit[1].'/', $id); + } + + if (preg_match(self::GitHubLink, $href, $gitHub) === 1 && ! in_array(strtolower($gitHub[1]), self::GitHubPages, true)) { + return new self($href, $gitHub[2].(isset($gitHub[4]) ? '#'.$gitHub[4] : ''), $gitHub[1].'/'); + } + + return new self($href, self::shorten($written)); + } + + /** + * Like X: without its scheme or "www.", with the whole host, and at most 20 + * characters after it, the last an ellipsis when it's cut. A cut never splits + * a percent-encoded character. + */ + private static function shorten(string $url): string + { + $url = (string) preg_replace('/^(?:https?:\/\/)?(?:www\.(?=[^\/?#]*\.))?/i', '', $url); + $host = (string) preg_replace('/[\/?#].*$/su', '', $url); + $path = mb_substr($url, mb_strlen($host)); + + if (mb_strlen($path) <= self::MaxShortenedPathLength) { + return $url; + } + + $kept = (string) preg_replace('/%[0-9A-Fa-f]?$/', '', mb_substr($path, 0, self::MaxShortenedPathLength - 1)); + + return $host.$kept.'…'; + } + + /** + * A URL with a scheme, or a bare domain: ASCII labels ending in an allowed + * TLD, then maybe a path, query or fragment. A bare domain can't follow a + * letter, digit, "_", ".", "/", "@" or "-", its TLD is all lowercase or all + * uppercase, and it ends the word without a "(" or another label after it. + */ + private static function pattern(): string + { + $topLevelDomains = implode('|', [...self::TopLevelDomains, ...array_map(strtoupper(...), self::TopLevelDomains)]); + + return '/\b[Hh][Tt][Tt][Pp][Ss]?:\/\/[^\s<>]+' + .'|(?]*)?/u'; + } + + /** + * Strips punctuation that ends a sentence rather than the URL, keeping + * closing brackets that pair with one inside it. + */ + private static function withoutTrailingPunctuation(string $url): string + { + do { + $previous = $url; + $url = mb_rtrim($url, '.,;:!?\'"'); + + foreach (['(' => ')', '[' => ']'] as $open => $close) { + if (str_ends_with($url, $close) && mb_substr_count($url, $open) < mb_substr_count($url, $close)) { + $url = mb_substr($url, 0, -1); + } + } + } while ($url !== $previous); + + return $url; + } +} diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index 5200661..ed713af 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -3,12 +3,13 @@ namespace App\Devnull\Data; /** - * A run of a paragraph: plain prose, a mention, inline code, or a standalone - * `/dev/null`, which draws as the wordmark. + * A run of a paragraph: plain prose, a mention, inline code, a link, or a + * standalone `/dev/null`, which draws as the wordmark. A link's text is the + * URL as written. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null) {} public function isMention(): bool { diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index ece9692..c4138a5 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -227,6 +227,27 @@ public function plainText(): string )); } + /** + * The links in the body, in the order it draws them. A long commit's + * preview is cut at a space, so its links are the first of these. + * + * @return list + */ + public function links(): array + { + $links = []; + + foreach ($this->content as $block) { + foreach ($block->runs as $run) { + if ($run->link !== null) { + $links[] = $run->link; + } + } + } + + return $links; + } + public function isDeleted(): bool { return $this->deletedAt !== null; diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index d52e061..c0788dd 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,8 +4,8 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with mentions linked, and `/dev/null` as the - * wordmark) and inline code flow together in paragraphs, and fenced code + * CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as + * the wordmark) and inline code flow together in paragraphs, and fenced code * blocks stand on their own. * * Mention offsets count code points into the whole body, but segments aren't @@ -60,12 +60,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withWordmarks($text.($cut ? self::Ellipsis : ''))); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [])); break; } - array_push($runs, ...self::withMentions($text, $start, $mentions)); + array_push($runs, ...self::withLinks($text, $start, $mentions)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -106,6 +106,38 @@ public static function blocks(string $body, ?array $segments, array $mentions = return $blocks; } + /** + * Splits prose that starts at $start in the body around the links inside + * it, then the prose between them around its mentions, as the web does. + * Prose that wasn't found in the body has no start, so no mentions. + * + * @param list $mentions + * @return list + */ + private static function withLinks(string $text, ?int $start, array $mentions): array + { + $runs = []; + $position = 0; + $prose = fn (string $part, int $at): array => $start === null + ? self::withWordmarks($part) + : self::withMentions($part, $start + $at, $mentions); + + foreach (BodyLink::in($text) as [$offset, $length, $link]) { + if ($offset > $position) { + array_push($runs, ...$prose(mb_substr($text, $position, $offset - $position), $position)); + } + + $runs[] = new BodyRun(mb_substr($text, $offset, $length), link: $link); + $position = $offset + $length; + } + + if ($position < mb_strlen($text)) { + array_push($runs, ...$prose(mb_substr($text, $position), $position)); + } + + return $runs; + } + /** * Splits prose that starts at $start in the body around the mentions inside it. * diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index e506842..f1829df 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -182,6 +182,23 @@ public function openImage(string $id, int $index = 0): void $this->openInBrowser(($this->shownCommit($id)?->images[$index] ?? null)?->url); } + /** + * Opens a link in a commit's body, by its place among the body's links: a + * commit on dev/null here in the app, anything else in the browser. + */ + public function openLink(string $id, int $index): void + { + $link = $this->shownCommit($id)?->links()[$index] ?? null; + + if ($link?->commit !== null) { + $this->openCommit($link->commit); + + return; + } + + $this->openInBrowser($link?->href); + } + /** * Share link: the web's copy-link button. The share sheet has Copy, and AirDrop and Messages besides. */ diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 144ef15..43b9764 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -11,6 +11,11 @@ slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null". + A link is Commit Blue and opens on a tap, numbered by its place among the body's links. Like the web, a URL shows + shortened, and a dev/null commit or a GitHub repository, issue or pull request shows as a mono name with the owner + muted, held on one line by a word joiner after its slash. The web's dev/null and GitHub marks before those are icons, + which text runs can't hold, so they're left out. + A body too long to show whole in a list shows its start, as devnull cut it, and "Show more" in Commit Blue, which opens the rest where it is. The commit a page is about ($focused) is always whole. $muted sets it in muted ink, for the commits around the one a page is about. @@ -18,6 +23,7 @@ @php($size ??= 'text-[15px]') @php($ink = ($muted ?? false) ? 'text-theme-ink-muted' : 'text-theme-ink') @php($folded = ! ($focused ?? false) && $commit->isFolded() && ! in_array($commit->id, $unfolded ?? [], true)) +@php($linkIndex = 0) @foreach ($folded ? $commit->previewContent : $commit->content as $block) @if ($block->isCode()) @@ -32,6 +38,10 @@ @elseif ($run->isCode) +@elseif ($run->link?->isNamed()) +⁠ +@elseif ($run->link) + @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 3de9bb7..694d369 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -223,6 +223,39 @@ ->and(strtolower($slash['dark_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-0'))); }); +it('links URLs in Commit Blue, opening a dev/null commit here and anything else in the browser', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ + 'id' => 'c1', + 'body' => 'Read https://www.example.com/a-long-path-to-the-release-notes, then https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8 and github.com/laravel/framework/pull/42', + ])]))]); + + $screen = Native::visit('/log'); + $primary = strtolower(config('native-ui.theme.light.primary')); + $link = findNode($screen->tree(), fn (array $node): bool => ($node['props']['text'] ?? null) === 'example.com/a-long-path-to-the…'); + $repository = findNode($screen->tree(), fn (array $node): bool => ($node['children'][0]['props']['text'] ?? null) === 'laravel/'); + + expect(paragraphRuns($screen, 'Read'))->toBe([ + ['Read '], + ['example.com/a-long-path-to-the…', 'link'], + [', then '], + ['Hacksore/5149ca8', 'link'], + [' and '], + ['laravel/framework#42', 'link'], + ]) + ->and(strtolower($link['props']['color']))->toBe($primary) + ->and($repository['props']['font_name'])->toBe('mono') + ->and(strtolower($repository['props']['color']))->toBe($primary) + ->and(strtolower($repository['children'][0]['props']['color']))->toBe(strtolower(config('native-ui.theme.light.ink-muted'))); + + $screen->press("openLink('c1', 0)") + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://www.example.com/a-long-path-to-the-release-notes') + ->press("openLink('c1', 2)") + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/laravel/framework/pull/42') + ->assertNoNavigation() + ->press("openLink('c1', 1)") + ->assertNavigatedTo('/log/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8'); +}); + it('shows a link preview as a compact row and opens it in the browser', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ 'id' => 'c1', diff --git a/tests/Pest.php b/tests/Pest.php index e8ddf7b..4a532fc 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -618,9 +618,10 @@ function pageJson(array $items, ?string $nextCursor = null, string $path = 'http /** * The runs of the first paragraph whose text contains $text, as [text, style]: - * 'mention' for a pressable run, 'code' for one on a wash, 'wordmark' for one - * drawn from runs of its own (read without its invisible line-break hints), or - * nothing for prose. + * 'mention' for a pressable @handle, 'link' for any other pressable run, + * 'code' for one on a wash, 'wordmark' for one drawn from runs of its own, or + * nothing for prose. A run drawn from runs of its own reads as their text, + * without the invisible line-break hints they hold. * * @return list */ @@ -645,11 +646,15 @@ function paragraphRuns(TestableComponent $screen, string $text): array expect($paragraph)->not->toBeNull("No paragraph with [{$text}] was rendered."); - return array_map(fn (array $run): array => match (true) { - isset($run['on_press']) => [$run['props']['text'], 'mention'], - isset($run['style']['bg_color']) => [$run['props']['text'], 'code'], - isset($run['children']) => [str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children']))), 'wordmark'], - default => [$run['props']['text']], + return array_map(function (array $run): array { + $text = $run['props']['text'] ?? str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children'] ?? []))); + + return match (true) { + isset($run['on_press']) => [$text, str_starts_with($text, '@') ? 'mention' : 'link'], + isset($run['style']['bg_color']) => [$text, 'code'], + isset($run['children']) => [$text, 'wordmark'], + default => [$text], + }; }, $paragraph['children']); } diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index fa56a80..135c372 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -16,10 +16,29 @@ function runsOf(array $blocks): array $run->isMention() => [$run->text, '@'.$run->username], $run->isCode => [$run->text, 'code'], $run->isWordmark => [$run->text, 'wordmark'], + $run->link !== null => [$run->text, 'link'], default => [$run->text], }, $blocks[0]->runs); } +/** + * The first link in the first paragraph: where it goes, its muted prefix, what + * it shows, and the dev/null commit it opens in the app. + * + * @param list $blocks + * @return array{0: string, 1: string, 2: string, 3: string|null} + */ +function linkIn(array $blocks): array +{ + foreach ($blocks[0]->runs as $run) { + if ($run->link !== null) { + return [$run->link->href, $run->link->prefix, $run->link->shown, $run->link->commit]; + } + } + + throw new RuntimeException('There is no link.'); +} + it('links mentions by code point, so emoji before them do not shift them', function () { // "🚀" is one code point but two UTF-16 units and four bytes. $body = '🚀🚀 shipped with @ada and 👩‍💻 @grace-h!'; @@ -137,9 +156,15 @@ function runsOf(array $blocks): array }); it('leaves /dev/null inside longer paths, words, URLs and usernames as text', function () { - $body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null @devnull'; + $body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null example.com/?to=/dev/null @devnull'; - expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]); + expect(runsOf(CommitBody::blocks($body, null)))->toBe([ + ['/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL '], + ['https://example.com/dev/null', 'link'], + [' '], + ['example.com/?to=/dev/null', 'link'], + [' @devnull'], + ]); }); it('draws the wordmark beside mentions but not in inline code or code blocks', function () { @@ -171,3 +196,106 @@ function runsOf(array $blocks): array ['…'], ]); }); + +it('links a URL, shortened like the web, and still places the mentions around it', function () { + $url = 'https://www.example.com/a-long-path-to-the-release-notes'; + $body = "Thanks @ORIGINAL! See {$url} and @futuredev."; + + $blocks = CommitBody::blocks($body, null, [new Mention('original', 7, 9), new Mention('futuredev', mb_strpos($body, '@futuredev'), 10)]); + + expect(runsOf($blocks))->toBe([ + ['Thanks '], + ['@ORIGINAL', '@original'], + ['! See '], + [$url, 'link'], + [' and '], + ['@futuredev', '@futuredev'], + ['.'], + ])->and(linkIn($blocks))->toBe([$url, '', 'example.com/a-long-path-to-the…', null]); +}); + +it('leaves sentence punctuation out of a link, but keeps a bracket that pairs inside it', function (string $body, string $link, string $after) { + expect(runsOf(CommitBody::blocks($body, null)))->toBe([['Read '], [$link, 'link'], [$after]]); +})->with([ + 'a full stop' => ['Read https://laravel.com/docs.', 'https://laravel.com/docs', '.'], + 'a closing bracket' => ['Read https://en.wikipedia.org/wiki/Foo_(bar)).', 'https://en.wikipedia.org/wiki/Foo_(bar)', ').'], +]); + +it('links a bare domain to https', function () { + expect(linkIn(CommitBody::blocks('Built with nativephp.com', null)))->toBe(['https://nativephp.com', '', 'nativephp.com', null]) + ->and(linkIn(CommitBody::blocks('Read laravel.com/docs', null)))->toBe(['https://laravel.com/docs', '', 'laravel.com/docs', null]); +}); + +it('leaves code, files and emails that look like a domain as text', function (string $body) { + expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]); +})->with(['user.name', 'socket.io.js', 'deploy.sh', 'bug.It', 'ada@example.com', 'array.at(-1)', 'nativephp.community']); + +it('leaves URLs in inline code and code blocks as code', function () { + $body = "See `https://inline.example.com`:\n```text\nhttps://block.example.com\n```"; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'See '], + ['type' => 'code', 'code' => 'https://inline.example.com'], + ['type' => 'text', 'text' => ':'], + ['type' => 'block', 'language' => 'text', 'html' => 'https://block.example.com'], + ]); + + expect(runsOf($blocks))->toBe([['See '], ['https://inline.example.com', 'code'], [':']]) + ->and($blocks[1]->code)->toBe('https://block.example.com'); +}); + +it('shows a dev/null commit link as its author and short hash, to open in the app', function () { + $url = 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135FE5149CA8'; + + $blocks = CommitBody::blocks("same root cause as {$url}.", null); + + expect(runsOf($blocks))->toBe([['same root cause as '], [$url, 'link'], ['.']]) + ->and(linkIn($blocks))->toBe([$url, 'Hacksore/', '5149ca8', '01a14a9d-7971-73ea-8f35-135fe5149ca8']); +}); + +it('keeps other dev/null links as URLs', function (string $url) { + expect(linkIn(CommitBody::blocks($url, null)))->toMatchArray([0 => $url, 1 => '', 3 => null]); +})->with([ + 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8/photos/1', + 'https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8?tab=replies', + 'https://example.com/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8', + 'https://dev-null.sh/@Hacksore', +]); + +it('shows a GitHub repository, issue or pull request as GitHub writes it', function (string $written, string $href, string $prefix, string $shown) { + expect(linkIn(CommitBody::blocks("built on {$written}.", null)))->toBe([$href, $prefix, $shown, null]); +})->with([ + 'a repository' => ['https://github.com/laravel/framework', 'https://github.com/laravel/framework', 'laravel/', 'framework'], + 'an issue' => ['https://github.com/laravel/framework/issues/42', 'https://github.com/laravel/framework/issues/42', 'laravel/', 'framework#42'], + 'a pull request' => ['https://github.com/laravel/framework/pull/42', 'https://github.com/laravel/framework/pull/42', 'laravel/', 'framework#42'], + 'a bare domain' => ['github.com/laravel/framework', 'https://github.com/laravel/framework', 'laravel/', 'framework'], + 'www' => ['https://www.github.com/laravel/framework', 'https://www.github.com/laravel/framework', 'laravel/', 'framework'], + 'capitals' => ['HTTPS://GitHub.com/laravel/framework', 'https://GitHub.com/laravel/framework', 'laravel/', 'framework'], + 'a dot in the name' => ['http://github.com/nunomaduro/dev-null.sh', 'http://github.com/nunomaduro/dev-null.sh', 'nunomaduro/', 'dev-null.sh'], +]); + +it('keeps other GitHub links as URLs', function (string $url) { + expect(linkIn(CommitBody::blocks($url, null)))->toMatchArray([0 => $url, 1 => '']); +})->with([ + 'https://github.com/laravel', + 'https://github.com/laravel/framework/', + 'https://github.com/laravel/framework/blob/12.x/README.md', + 'https://github.com/laravel/framework/tree/12.x', + 'https://github.com/laravel/framework/releases', + 'https://github.com/laravel/framework/commit/4fa008f', + 'https://github.com/laravel/framework/pull/42/files', + 'https://github.com/laravel/framework/pull/42/', + 'https://github.com/laravel/framework/issues/0', + 'https://github.com/laravel/framework/issues/042', + 'https://github.com/laravel/framework/issues', + 'https://github.com/laravel/framework/issues/42#issuecomment-1', + 'https://github.com/laravel/framework/issues/42?page=2', + 'https://github.com/laravel/framework/discussions/42', + 'https://github.com/laravel/framework?tab=readme', + 'https://github.com/laravel/framework#readme', + 'https://github.com/features/actions', + 'https://github.com/orgs/laravel', + 'https://github.com/settings/profile', + 'https://gist.github.com/laravel/framework', + 'https://notgithub.com/laravel/framework', +]);