From aefd5548ccc7b55f45239d2ebec04dd9ae02b953 Mon Sep 17 00:00:00 2001 From: Freek Van der Herten Date: Tue, 28 Jul 2026 09:39:11 -0400 Subject: [PATCH] Add UTM parameters to links pointing at ohdear.app Links to ohdear.app are now tagged so we can tell which channel drove the click. The tracked domains live in config/utm.php. Co-Authored-By: Claude --- app/Jobs/PostOnBlueskyJob.php | 4 +- app/Models/Ad.php | 10 ++ app/Models/Link.php | 10 ++ app/Models/Post.php | 28 ++++- app/Models/Presenters/PostPresenter.php | 4 + app/Models/Video.php | 10 ++ app/Services/NewsletterGenerator.php | 29 ++--- app/Services/Utm/UtmParameters.php | 73 ++++++++++++ app/Services/Utm/UtmTagger.php | 90 ++++++++++++++ config/utm.php | 14 +++ resources/views/front/components/ad.blade.php | 2 +- .../front/components/postHeader.blade.php | 2 +- .../front/layouts/partials/bio.blade.php | 2 +- resources/views/front/pages/about.blade.php | 2 +- .../front/pages/community/index.blade.php | 8 +- .../views/front/posts/partials/list.blade.php | 4 +- resources/views/front/posts/show.blade.php | 4 +- .../front/speaking/partials/video.blade.php | 2 +- .../views/mails/links/approved.blade.php | 2 +- tests/Feature/UtmTaggingTest.php | 112 ++++++++++++++++++ tests/Unit/Services/UtmTaggerTest.php | 93 +++++++++++++++ 21 files changed, 469 insertions(+), 36 deletions(-) create mode 100644 app/Services/Utm/UtmParameters.php create mode 100644 app/Services/Utm/UtmTagger.php create mode 100644 config/utm.php create mode 100644 tests/Feature/UtmTaggingTest.php create mode 100644 tests/Unit/Services/UtmTaggerTest.php diff --git a/app/Jobs/PostOnBlueskyJob.php b/app/Jobs/PostOnBlueskyJob.php index f05a5ed4..c2038668 100644 --- a/app/Jobs/PostOnBlueskyJob.php +++ b/app/Jobs/PostOnBlueskyJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Models\Post; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -41,7 +43,7 @@ public function handle(): void ->withoutAutomaticEmbeds() ->language(['en-US']) ->embed(new External( - uri: $this->post->promotional_url, + uri: UtmTagger::tagUrl($this->post->promotional_url, UtmParameters::forBluesky($this->post)), title: $this->post->title, description: $this->post->text, )); diff --git a/app/Models/Ad.php b/app/Models/Ad.php index 66c7e65e..009bd8db 100644 --- a/app/Models/Ad.php +++ b/app/Models/Ad.php @@ -3,8 +3,11 @@ namespace App\Models; use App\Services\CommonMark\CommonMark; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; @@ -68,6 +71,13 @@ public function current(Builder $query): void ->whereDate('ends_at', '>=', $now); } + public function htmlWithUtm(): Attribute + { + return new Attribute(function () { + return UtmTagger::tagHtml($this->html ?? '', UtmParameters::forAd($this)); + }); + } + public function getFormattedTextAttribute(): string { return CommonMark::convertToHtml($this->text); diff --git a/app/Models/Link.php b/app/Models/Link.php index 66a04f46..102e0128 100644 --- a/app/Models/Link.php +++ b/app/Models/Link.php @@ -5,8 +5,11 @@ use App\Enums\LinkStatus; use App\Models\Concerns\HasSlug; use App\Models\Concerns\Sluggable; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -50,6 +53,13 @@ public function isRejected(): bool return $this->status === LinkStatus::Rejected->value; } + public function urlWithUtm(): Attribute + { + return new Attribute(function () { + return UtmTagger::tagUrl($this->url ?? '', UtmParameters::forLink($this)); + }); + } + public function getHostUrlAttribute(): string { return parse_url($this->url)['host'] ?? ''; diff --git a/app/Models/Post.php b/app/Models/Post.php index 3ed38e97..95d70283 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -6,6 +6,8 @@ use App\Models\Concerns\HasSlug; use App\Models\Concerns\Sluggable; use App\Models\Presenters\PostPresenter; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Carbon\Carbon; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; @@ -88,12 +90,26 @@ public function scheduled(Builder $query): void ->whereNotNull('publish_date'); } + public function htmlWithUtm(): Attribute + { + return new Attribute(function () { + return UtmTagger::tagHtml($this->html ?? '', UtmParameters::forPost($this)); + }); + } + + public function externalUrlWithUtm(): Attribute + { + return new Attribute(function () { + return UtmTagger::tagUrl($this->external_url ?? '', UtmParameters::forPost($this)); + }); + } + public function getHtmlWithExternalUrlAttribute() { - $html = $this->html; + $html = $this->html_with_utm; if (! $this->isTweet() && $this->external_url) { - $html .= PHP_EOL.PHP_EOL."Read more"; + $html .= PHP_EOL.PHP_EOL."Read more"; } return $html; @@ -238,8 +254,10 @@ public function toTweet(): string $twitterAuthorString = " (by @{$twitterHandle})"; } + $promotionalUrl = UtmTagger::tagUrl($this->promotional_url, UtmParameters::forTwitter($this)); + return $this->emoji.' '.$this->title.$twitterAuthorString - .PHP_EOL.$this->promotional_url + .PHP_EOL.$promotionalUrl .PHP_EOL.$tags; } @@ -261,8 +279,10 @@ public function toToot(): string ->map(fn (string $tagName) => '#'.str_replace(' ', '', $tagName)) ->implode(' '); + $promotionalUrl = UtmTagger::tagUrl($this->promotional_url, UtmParameters::forMastodon($this)); + return $this->emoji.' '.$this->title - .PHP_EOL.$this->promotional_url + .PHP_EOL.$promotionalUrl .PHP_EOL.$tags; } diff --git a/app/Models/Presenters/PostPresenter.php b/app/Models/Presenters/PostPresenter.php index 82e98390..89789432 100644 --- a/app/Models/Presenters/PostPresenter.php +++ b/app/Models/Presenters/PostPresenter.php @@ -4,6 +4,8 @@ use App\Models\Post; use App\Services\CommonMark\CommonMark; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Illuminate\Support\Str; /** @mixin Post */ @@ -21,6 +23,8 @@ public function getExcerptAttribute(): string $excerpt = CommonMark::convertToHtml($excerpt, highlightCode: true); + $excerpt = UtmTagger::tagHtml($excerpt, UtmParameters::forPost($this)); + return trim($excerpt); } diff --git a/app/Models/Video.php b/app/Models/Video.php index d738c050..0dc963b5 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -3,6 +3,9 @@ namespace App\Models; use App\Services\CommonMark\CommonMark; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class Video extends Model @@ -15,4 +18,11 @@ public static function booted() }); }); } + + public function htmlWithUtm(): Attribute + { + return new Attribute(function () { + return UtmTagger::tagHtml($this->html ?? '', UtmParameters::forVideo($this)); + }); + } } diff --git a/app/Services/NewsletterGenerator.php b/app/Services/NewsletterGenerator.php index d126e2f5..39c7ba68 100644 --- a/app/Services/NewsletterGenerator.php +++ b/app/Services/NewsletterGenerator.php @@ -4,42 +4,37 @@ use App\Models\Link; use App\Models\Post; +use App\Services\Utm\UtmParameters; +use App\Services\Utm\UtmTagger; use Carbon\Carbon; use Illuminate\Support\Collection; use NumberFormatter; class NewsletterGenerator { - protected Carbon $startDate; + public function __construct( + protected Carbon $startDate, + protected Carbon $endDate, + protected int $editionNumber, + ) {} - protected Carbon $endDate; - - protected string $editionNumber; - - public function __construct(Carbon $startDate, Carbon $endDate, int $editionNumber) - { - $this->startDate = $startDate; - - $this->endDate = $endDate; - - $this->editionNumber = $this->ordinal($editionNumber); - } - - public function getMarkdown() + public function getMarkdown(): string { $recentPosts = $this->getRecentPosts(); $recentTweets = $this->getRecentTweets(); $communityLinks = $this->getRecentCommunityLinks(); $oldPosts = $this->getOldPosts(); - $editionNumber = $this->editionNumber; + $editionNumber = $this->ordinal($this->editionNumber); - return view('newsletter.template', compact( + $markdown = view('newsletter.template', compact( 'recentPosts', 'recentTweets', 'communityLinks', 'oldPosts', 'editionNumber', ))->render(); + + return UtmTagger::tagMarkdown($markdown, UtmParameters::forNewsletter($this->editionNumber)); } protected function getRecentPosts(): Collection diff --git a/app/Services/Utm/UtmParameters.php b/app/Services/Utm/UtmParameters.php new file mode 100644 index 00000000..0478d141 --- /dev/null +++ b/app/Services/Utm/UtmParameters.php @@ -0,0 +1,73 @@ +idSlug()}"); + } + + public static function forLink(Link $link): self + { + return new self('freek.dev', 'referral', "community-link-{$link->id}-{$link->slug}"); + } + + public static function forAd(Ad $ad): self + { + return new self('freek.dev', 'banner', "ad-{$ad->id}"); + } + + public static function forVideo(Video $video): self + { + return new self('freek.dev', 'referral', "video-{$video->id}-".Str::slug($video->title)); + } + + public static function forNewsletter(int $editionNumber): self + { + return new self('newsletter', 'email', "freek-dev-issue-{$editionNumber}"); + } + + public static function forTwitter(Post $post): self + { + return self::forSocialNetwork('twitter', $post); + } + + public static function forMastodon(Post $post): self + { + return self::forSocialNetwork('mastodon', $post); + } + + public static function forBluesky(Post $post): self + { + return self::forSocialNetwork('bluesky', $post); + } + + protected static function forSocialNetwork(string $network, Post $post): self + { + return new self($network, 'social', "blogpost-{$post->idSlug()}"); + } + + /** @return array{utm_source: string, utm_medium: string, utm_campaign: string} */ + public function toArray(): array + { + return [ + 'utm_source' => $this->source, + 'utm_medium' => $this->medium, + 'utm_campaign' => $this->campaign, + ]; + } +} diff --git a/app/Services/Utm/UtmTagger.php b/app/Services/Utm/UtmTagger.php new file mode 100644 index 00000000..e880e5a2 --- /dev/null +++ b/app/Services/Utm/UtmTagger.php @@ -0,0 +1,90 @@ +toArray()); + + return $fragment === null + ? $url + : "{$url}#{$fragment}"; + } + + public static function tagHtml(string $html, UtmParameters $utmParameters): string + { + return preg_replace_callback( + '/href=(["\'])(.*?)\1/i', + function (array $matches) use ($utmParameters): string { + $url = html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5); + + $taggedUrl = static::tagUrl($url, $utmParameters); + + if ($taggedUrl === $url) { + return $matches[0]; + } + + return 'href='.$matches[1].htmlspecialchars($taggedUrl, ENT_QUOTES | ENT_HTML5).$matches[1]; + }, + $html + ) ?? $html; + } + + public static function tagMarkdown(string $markdown, UtmParameters $utmParameters): string + { + return preg_replace_callback( + '/\]\(([^)\s]+)((?:\s+[^)]*)?)\)/', + fn (array $matches): string => '](' + .static::tagUrl($matches[1], $utmParameters) + .$matches[2] + .')', + $markdown + ) ?? $markdown; + } + + protected static function shouldBeTagged(string $url): bool + { + $urlParts = parse_url($url); + + if (! is_string($urlParts['host'] ?? null)) { + return false; + } + + parse_str($urlParts['query'] ?? '', $queryParameters); + + if (isset($queryParameters['utm_source'])) { + return false; + } + + return static::isTrackedHost($urlParts['host']); + } + + protected static function isTrackedHost(string $host): bool + { + $host = strtolower($host); + + return Arr::first( + config('utm.tracked_domains'), + fn (string $trackedDomain) => $host === $trackedDomain || str_ends_with($host, ".{$trackedDomain}"), + ) !== null; + } +} diff --git a/config/utm.php b/config/utm.php new file mode 100644 index 00000000..b7249510 --- /dev/null +++ b/config/utm.php @@ -0,0 +1,14 @@ + [ + 'ohdear.app', + ], + +]; diff --git a/resources/views/front/components/ad.blade.php b/resources/views/front/components/ad.blade.php index d41d66db..d1d54f3e 100644 --- a/resources/views/front/components/ad.blade.php +++ b/resources/views/front/components/ad.blade.php @@ -1,5 +1,5 @@ @if($ad)
- {!! $ad->html !!} + {!! $ad->html_with_utm !!}
@endif diff --git a/resources/views/front/components/postHeader.blade.php b/resources/views/front/components/postHeader.blade.php index 22c6b920..88c67d49 100644 --- a/resources/views/front/components/postHeader.blade.php +++ b/resources/views/front/components/postHeader.blade.php @@ -36,7 +36,7 @@ @if($post->external_url) – - + {{ $post->external_url_host }} @elseif($post->isOriginal()) by {{ $post->author }} diff --git a/resources/views/front/layouts/partials/bio.blade.php b/resources/views/front/layouts/partials/bio.blade.php index 302b9902..36f54ee8 100644 --- a/resources/views/front/layouts/partials/bio.blade.php +++ b/resources/views/front/layouts/partials/bio.blade.php @@ -1,7 +1,7 @@
Freek Van der Herten

- I'm a Laravel developer at Spatie and Oh Dear. I maintain 300+ open source packages for the Laravel community. + I'm a Laravel developer at Spatie and Oh Dear. I maintain 300+ open source packages for the Laravel community.

diff --git a/resources/views/front/pages/about.blade.php b/resources/views/front/pages/about.blade.php index 2a4c9ca6..20225425 100644 --- a/resources/views/front/pages/about.blade.php +++ b/resources/views/front/pages/about.blade.php @@ -18,7 +18,7 @@ class="sm:w-48 sm:ml-3 mb-6 sm:mb-0 sm:rounded-full sm:float-right" Side projects

- Together with my friend Mattias, I run Oh Dear, a website monitoring service. It can notify you when your site is down, when it contains broken links, when it finds mixed content, and much more. Try it out! + Together with my friend Mattias, I run Oh Dear, a website monitoring service. It can notify you when your site is down, when it contains broken links, when it finds mixed content, and much more. Try it out!

Outside of programming, I'm passionate about music. I record music in Ableton and have released a couple of EPs. You can listen to them on Spotify or Apple Music. diff --git a/resources/views/front/pages/community/index.blade.php b/resources/views/front/pages/community/index.blade.php index 4d7601a7..46888558 100644 --- a/resources/views/front/pages/community/index.blade.php +++ b/resources/views/front/pages/community/index.blade.php @@ -43,17 +43,17 @@ class="underline hover:text-black" ">

- {{ $link->title }} + {{ $link->title }}

- + – - + {{ $link->host_url }} - submitted by @@ -73,7 +73,7 @@ class="underline hover:text-black"

- Read more + Read more [{{ $link->host_url }}]

diff --git a/resources/views/front/posts/partials/list.blade.php b/resources/views/front/posts/partials/list.blade.php index 5ddfaca1..533109b6 100644 --- a/resources/views/front/posts/partials/list.blade.php +++ b/resources/views/front/posts/partials/list.blade.php @@ -9,7 +9,7 @@ @@ -18,7 +18,7 @@ class="mb-12 md:mb-24'" @unless($post->isTweet())

@if($post->external_url) - + Read more [{{ $post->external_url_host }}] @else diff --git a/resources/views/front/posts/show.blade.php b/resources/views/front/posts/show.blade.php index e082066f..9c5aeef8 100644 --- a/resources/views/front/posts/show.blade.php +++ b/resources/views/front/posts/show.blade.php @@ -1,12 +1,12 @@ - {!! $post->html !!} + {!! $post->html_with_utm !!} @unless($post->isTweet()) @if($post->external_url)

- + Read more [{{ $post->external_url_host }}]

diff --git a/resources/views/front/speaking/partials/video.blade.php b/resources/views/front/speaking/partials/video.blade.php index 6c895ee0..b1384ea7 100644 --- a/resources/views/front/speaking/partials/video.blade.php +++ b/resources/views/front/speaking/partials/video.blade.php @@ -8,7 +8,7 @@
- {!! $video->html !!} + {!! $video->html_with_utm !!}
diff --git a/resources/views/mails/links/approved.blade.php b/resources/views/mails/links/approved.blade.php index ff70c366..630bcefd 100644 --- a/resources/views/mails/links/approved.blade.php +++ b/resources/views/mails/links/approved.blade.php @@ -5,7 +5,7 @@ To thank you for this link, I'd like to offer you a coupon which grants you a discount when purchasing one of these products of mine: -- [Oh Dear](https://ohdear.app) will notify you whenever your website goes down. Unlike most other services, Oh Dear does not only monitor your homepage, but your entire website. It can notify you when you have broken links or mixed content. It will also monitor the performance of your site, your SSL certificates, provides public status page like [this one](https://status.laravel.com), and much more. The coupon offers you a discount of 5 EUR on your first bill. +- [Oh Dear](https://ohdear.app?utm_source=freek.dev&utm_medium=email&utm_campaign=link-approved) will notify you whenever your website goes down. Unlike most other services, Oh Dear does not only monitor your homepage, but your entire website. It can notify you when you have broken links or mixed content. It will also monitor the performance of your site, your SSL certificates, provides public status page like [this one](https://status.laravel.com), and much more. The coupon offers you a discount of 5 EUR on your first bill. - [Laravel Beyond CRUD](https://laravel-beyond-crud.com) is both a book and a video course that teaches you how my team at [Spatie](https://spatie.be) builds larger-than-average Laravel apps. Many things apply to smaller projects too. The coupon grants you a 10% discount. - In the [Laravel Package Training video course](https://laravelpackage.training) you'll learn how to create quality framework agnostic PHP and Laravel packages. We'll also source dive a couple of my packages together. I'm sure you will pick up some coding tricks! The coupon grants you a 10% discount. - [Mailcoach](https://mailcoach.app) is a premium Laravel package that can help sending email campaigns of any size in an affordable way. The coupon grants you a 10% discount. diff --git a/tests/Feature/UtmTaggingTest.php b/tests/Feature/UtmTaggingTest.php new file mode 100644 index 00000000..2a52dee7 --- /dev/null +++ b/tests/Feature/UtmTaggingTest.php @@ -0,0 +1,112 @@ +set('utm.tracked_domains', ['ohdear.app']); + + $this->post = Post::factory()->create([ + 'title' => 'My post', + 'text' => 'Try [Oh Dear](https://ohdear.app) and [Spatie](https://spatie.be).', + 'published' => true, + 'publish_date' => now()->subDay(), + 'original_content' => false, + ]); +}); + +it('tags links in a post body with the blog source', function () { + expect($this->post->html_with_utm) + ->toContain("utm_source=freek.dev&utm_medium=referral&utm_campaign=blogpost-{$this->post->idSlug()}") + ->toContain('href="https://spatie.be"'); +}); + +it('shows tagged links on the post page', function () { + $this->get($this->post->url) + ->assertOk() + ->assertSee("utm_campaign=blogpost-{$this->post->idSlug()}", escape: false); +}); + +it('tags links in the feed summary', function () { + $this->post->update(['external_url' => 'https://ohdear.app/blog/some-article']); + + expect($this->post->fresh()->html_with_external_url) + ->toContain("Read more"); +}); + +it('tags links in a post excerpt', function () { + expect($this->post->excerpt)->toContain('utm_source=freek.dev'); +}); + +it('tags the external url of a post on listing pages', function () { + $this->post->update(['external_url' => 'https://ohdear.app/blog/some-article']); + + $this->get(route('home')) + ->assertOk() + ->assertSee("utm_campaign=blogpost-{$this->post->idSlug()}", escape: false); +}); + +it('tags a community link on the community page', function () { + $link = Link::factory()->create([ + 'url' => 'https://ohdear.app/blog/some-article', + 'status' => LinkStatus::Approved->value, + 'publish_date' => now()->subDay(), + ]); + + expect($link->url_with_utm) + ->toBe("https://ohdear.app/blog/some-article?utm_source=freek.dev&utm_medium=referral&utm_campaign=community-link-{$link->id}-{$link->slug}"); + + $this->get(route('community.index')) + ->assertOk() + ->assertSee("utm_campaign=community-link-{$link->id}-{$link->slug}", escape: false); +}); + +it('tags links in an ad', function () { + $ad = Ad::factory()->create([ + 'text' => 'Monitor with [Oh Dear](https://ohdear.app).', + ]); + + expect($ad->html_with_utm) + ->toContain("utm_source=freek.dev&utm_medium=banner&utm_campaign=ad-{$ad->id}"); +}); + +it('tags links in a video description', function () { + $video = Video::create([ + 'title' => 'Monitoring your site', + 'text' => 'We use [Oh Dear](https://ohdear.app).', + 'embed' => '', + ]); + + expect($video->fresh()->html_with_utm) + ->toContain("utm_source=freek.dev&utm_medium=referral&utm_campaign=video-{$video->id}-monitoring-your-site"); +}); + +it('tags links in the newsletter with the newsletter source', function () { + $markdown = (new NewsletterGenerator(now()->subWeek(), now(), 351))->getMarkdown(); + + expect($markdown) + ->toContain('[Oh Dear](https://ohdear.app?utm_source=newsletter&utm_medium=email&utm_campaign=freek-dev-issue-351)') + ->toContain('Welcome to the 351st freek.dev newsletter!') + ->toContain('[Mailcoach](https://mailcoach.app)'); +}); + +it('tags the promotional url in a tweet and a toot', function () { + $this->post->update(['external_url' => 'https://ohdear.app/blog/some-article']); + + $post = $this->post->fresh(); + + expect($post->toTweet()) + ->toContain("https://ohdear.app/blog/some-article?utm_source=twitter&utm_medium=social&utm_campaign=blogpost-{$post->idSlug()}"); + + expect($post->toToot()) + ->toContain("https://ohdear.app/blog/some-article?utm_source=mastodon&utm_medium=social&utm_campaign=blogpost-{$post->idSlug()}"); +}); + +it('does not tag a promotional url that points to freek.dev', function () { + expect($this->post->toTweet())->toContain($this->post->url) + ->not()->toContain('utm_source'); +}); diff --git a/tests/Unit/Services/UtmTaggerTest.php b/tests/Unit/Services/UtmTaggerTest.php new file mode 100644 index 00000000..49182f6c --- /dev/null +++ b/tests/Unit/Services/UtmTaggerTest.php @@ -0,0 +1,93 @@ +set('utm.tracked_domains', ['ohdear.app']); + + $this->utmParameters = new UtmParameters('freek.dev', 'referral', 'blogpost-1-my-post'); + + $this->query = 'utm_source=freek.dev&utm_medium=referral&utm_campaign=blogpost-1-my-post'; +}); + +it('tags a url on a tracked domain', function () { + expect(UtmTagger::tagUrl('https://ohdear.app/pricing', $this->utmParameters)) + ->toBe("https://ohdear.app/pricing?{$this->query}"); +}); + +it('tags a url on a subdomain of a tracked domain', function () { + expect(UtmTagger::tagUrl('https://www.ohdear.app', $this->utmParameters)) + ->toBe("https://www.ohdear.app?{$this->query}"); +}); + +it('appends to an existing query string', function () { + expect(UtmTagger::tagUrl('https://ohdear.app/docs?page=2', $this->utmParameters)) + ->toBe("https://ohdear.app/docs?page=2&{$this->query}"); +}); + +it('keeps the fragment at the end', function () { + expect(UtmTagger::tagUrl('https://ohdear.app/docs#installation', $this->utmParameters)) + ->toBe("https://ohdear.app/docs?{$this->query}#installation"); +}); + +it('leaves a url that already carries utm parameters alone', function () { + $url = 'https://ohdear.app?utm_source=somewhere-else'; + + expect(UtmTagger::tagUrl($url, $this->utmParameters))->toBe($url); +}); + +it('leaves urls that are not on a tracked domain alone', function (string $url) { + expect(UtmTagger::tagUrl($url, $this->utmParameters))->toBe($url); +})->with([ + 'https://spatie.be/products', + 'https://notohdear.app', + 'https://ohdear.app.evil.com', + '/relative/path', + '#anchor', + 'mailto:freek@spatie.be', + '', +]); + +it('tags hrefs in html', function () { + $html = '

Oh Dear and Spatie

'; + + expect(UtmTagger::tagHtml($html, $this->utmParameters)) + ->toContain('href="https://ohdear.app?utm_source=freek.dev&utm_medium=referral&utm_campaign=blogpost-1-my-post"') + ->toContain('href="https://spatie.be"'); +}); + +it('tags hrefs wrapped in single quotes', function () { + $html = "Oh Dear"; + + expect(UtmTagger::tagHtml($html, $this->utmParameters)) + ->toContain("href='https://ohdear.app?utm_source=freek.dev&utm_medium=referral"); +}); + +it('decodes an html encoded query string before tagging', function () { + $html = 'Oh Dear'; + + expect(UtmTagger::tagHtml($html, $this->utmParameters)) + ->toContain('href="https://ohdear.app/docs?a=1&b=2&utm_source=freek.dev'); +}); + +it('tags markdown links', function () { + $markdown = '[Oh Dear](https://ohdear.app) and [Spatie](https://spatie.be)'; + + expect(UtmTagger::tagMarkdown($markdown, $this->utmParameters)) + ->toBe("[Oh Dear](https://ohdear.app?{$this->query}) and [Spatie](https://spatie.be)"); +}); + +it('keeps the title of a markdown link', function () { + $markdown = '[Oh Dear](https://ohdear.app "Uptime monitoring")'; + + expect(UtmTagger::tagMarkdown($markdown, $this->utmParameters)) + ->toBe("[Oh Dear](https://ohdear.app?{$this->query} \"Uptime monitoring\")"); +}); + +it('can track multiple domains', function () { + config()->set('utm.tracked_domains', ['ohdear.app', 'mailcoach.app']); + + expect(UtmTagger::tagUrl('https://mailcoach.app', $this->utmParameters)) + ->toBe("https://mailcoach.app?{$this->query}"); +});