Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/Jobs/PostOnBlueskyJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
));
Expand Down
10 changes: 10 additions & 0 deletions app/Models/Ad.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions app/Models/Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'] ?? '';
Expand Down
28 changes: 24 additions & 4 deletions app/Models/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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."<a href='{$this->external_url}'>Read more</a>";
$html .= PHP_EOL.PHP_EOL."<a href='{$this->external_url_with_utm}'>Read more</a>";
}

return $html;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions app/Models/Presenters/PostPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -21,6 +23,8 @@ public function getExcerptAttribute(): string

$excerpt = CommonMark::convertToHtml($excerpt, highlightCode: true);

$excerpt = UtmTagger::tagHtml($excerpt, UtmParameters::forPost($this));

return trim($excerpt);
}

Expand Down
10 changes: 10 additions & 0 deletions app/Models/Video.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,4 +18,11 @@ public static function booted()
});
});
}

public function htmlWithUtm(): Attribute
{
return new Attribute(function () {
return UtmTagger::tagHtml($this->html ?? '', UtmParameters::forVideo($this));
});
}
}
29 changes: 12 additions & 17 deletions app/Services/NewsletterGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions app/Services/Utm/UtmParameters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace App\Services\Utm;

use App\Models\Ad;
use App\Models\Link;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Support\Str;

readonly class UtmParameters
{
public function __construct(
public string $source,
public string $medium,
public string $campaign,
) {}

public static function forPost(Post $post): self
{
return new self('freek.dev', 'referral', "blogpost-{$post->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,
];
}
}
90 changes: 90 additions & 0 deletions app/Services/Utm/UtmTagger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Services\Utm;

use Illuminate\Support\Arr;

/**
* Adds UTM parameters to links pointing at one of the domains in `config/utm.php`.
*
* When a regex fails, `preg_replace_callback` returns null. In that case the taggers
* hand back the original content: links without UTM parameters are a lot better
* than a blank blog post.
*/
class UtmTagger
{
public static function tagUrl(string $url, UtmParameters $utmParameters): string
{
if (! static::shouldBeTagged($url)) {
return $url;
}

[$url, $fragment] = array_pad(explode('#', $url, 2), 2, null);

$separator = str_contains($url, '?') ? '&' : '?';

$url .= $separator.http_build_query($utmParameters->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;
}
}
Loading
Loading