How this site was made¶
This website has been one of my favourite sideprojects thusfar, and although I have a lot of things left to implement, I wanted to showcase some of it's current bells and whistles, explain it's inner workings, and how I use it.
The Initial Idea¶
I wanted a personal blog site that was
- easy to publish to,
- looked pretty, without much effort to make it so,
- allowed for nice looking code snippet examples,
- was very speedy and performant (fast page loads),
- was a joy to navigate and use.
The Post Lifecycle¶
Let's look at what happens from the initial writing of the post, up to ultimately seeing it on a page such as this one.
Writing the post¶
I write my blog posts in markdown, which to those unfamiliar, looks something like this:
1# How this site was made23This website has been one of my _favourite_ sidepro...45## The Initial Idea67I wanted a personal blog si...8- easy to publish to,9- looked pretty nice, without mu...10- ...
1# How this site was made23This website has been one of my _favourite_ sidepro...45## The Initial Idea67I wanted a personal blog si...8- easy to publish to,9- looked pretty nice, without mu...10- ...
Doing all your formatting in this kind of fashion, means you can move really fast compared to something fancier like a word document.
After I am done writing (this post being no exception), I would then use my handy shell script on my local computer to publish the post to my site:
1#!/bin/sh23# Check if a file was provided4if [ -z "$1" ]; then5 echo "Usage: $(basename "$0") <markdown file>"6 exit 17fi89# Get the full path to the markdown file10post_file="$(realpath "$1")"1112# Extract the title from the frontmatter13title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')1415# Check if the title is found in the frontmatter16if [ -z "$title" ]; then17 echo "Error: Title is missing in the frontmatter."18 exit 119fi2021# Read API token securely22api_token="$(pass personal/api-tokens/personal-blog)"2324# Read the contents of the markdown file25body=$(cat "$post_file")2627# Make the POST request28curl -L -X POST "https://pevermeulen.com/api/posts" \29 --header "Authorization: Bearer $api_token" \30 --header "Accept: application/json" \31 --data-urlencode "title=$title" \32 --data-urlencode "body=$body"
1#!/bin/sh23# Check if a file was provided4if [ -z "$1" ]; then5 echo "Usage: $(basename "$0") <markdown file>"6 exit 17fi89# Get the full path to the markdown file10post_file="$(realpath "$1")"1112# Extract the title from the frontmatter13title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')1415# Check if the title is found in the frontmatter16if [ -z "$title" ]; then17 echo "Error: Title is missing in the frontmatter."18 exit 119fi2021# Read API token securely22api_token="$(pass personal/api-tokens/personal-blog)"2324# Read the contents of the markdown file25body=$(cat "$post_file")2627# Make the POST request28curl -L -X POST "https://pevermeulen.com/api/posts" \29 --header "Authorization: Bearer $api_token" \30 --header "Accept: application/json" \31 --data-urlencode "title=$title" \32 --data-urlencode "body=$body"
I invoke the script like so:
1create-blog-post how_this_site_was_made.md
1create-blog-post how_this_site_was_made.md
I just press enter, and the rest is then handled by the site itself!
Conversion of markdown to html¶
Once a post reaches the store endpoint of the project and is about to be saved to the database, we generate the html for the post using the excellent Commonmark php library:
1class PostObserver2{3 public function saving(Post $post): void4 {5 $convertedMarkdown = Markdown::convert($post->markdown);67 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {8 $frontmatter = $convertedMarkdown->getFrontMatter();9 } else {10 throw new FrontmatterMissingException;11 }1213 $html = $convertedMarkdown->getContent();1415 $post->html = $html;
16 $post->frontmatter = new Frontmatter(17 title: $frontmatter['title'] ?? $post->title,18 description: $frontmatter['description'],19 tags: $frontmatter['tags'],20 author: $frontmatter['author'] ?? 'PE Vermeulen',21 );2223 $features = [];2425 if (Str::contains($html, 'lite-youtube')) {26 $features[] = PostFeature::VIDEO;27 }2829 if (Str::contains($html, '<pre>')) {30 $features[] = PostFeature::CODE;31 }3233 $post->features = $features;34 }
1class PostObserver2{3 public function saving(Post $post): void4 {5 $convertedMarkdown = Markdown::convert($post->markdown);67 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {8 $frontmatter = $convertedMarkdown->getFrontMatter();9 } else {10 throw new FrontmatterMissingException;11 }1213 $html = $convertedMarkdown->getContent();1415 $post->html = $html;
16 $post->frontmatter = new Frontmatter(17 title: $frontmatter['title'] ?? $post->title,18 description: $frontmatter['description'],19 tags: $frontmatter['tags'],20 author: $frontmatter['author'] ?? 'PE Vermeulen',21 );2223 $features = [];2425 if (Str::contains($html, 'lite-youtube')) {26 $features[] = PostFeature::VIDEO;27 }2829 if (Str::contains($html, '<pre>')) {30 $features[] = PostFeature::CODE;31 }3233 $post->features = $features;34 }
Using this, we can, among other things, hook into the parsing and rendering processes of the html conversion and generate cool stuff, like a table of contents based on the page's headings, a shout out on the bottom of the page for torchlight, who provides syntax highlighting for our code snippets, if our page does in fact contain code snippets, etc.
I wanted the html to immediately be available on page request, instead of doing the markdown conversion every single time, so the solution was to simply store the html in a table column.
Displaying the post to the user¶
You might at this point be wondering how the site looks the way it does, if most of the rendered html must be classless elements.
Our saviour here was classless picocss, which essentially gives us a good enough style language for the site, by way of mostly element selectors.
Our markdown to html converter leaves us with a naked, classless, html body which is pretty much ripe to apply picocss on top of. Very little CSS was written for this project by hand, making style maintenance a breeze.
Pico also promotes the use of symantic html tags, which help a lot with accessibility and search engine optimization, which we will take a look at next.
SEO¶
If, before I worked on this project, you had told me I would end up finding SEO interesting, I would not have believed you. It was, to my surprise, a really fun rabbit hole to dive into, it turns out.
Sitemap¶
For our sitemap, we make use of this awesome sitemap generator made by the folks at Spatie - laravel-sitemap.
It runs on a daily cron schedule, and assists search engines with the indexing of our site:
1Artisan::command('sitemap:generate', function () {2 SitemapGenerator::create(config('app.url'))3 ->writeToFile(public_path('sitemap.xml'));4});
1Artisan::command('sitemap:generate', function () {2 SitemapGenerator::create(config('app.url'))3 ->writeToFile(public_path('sitemap.xml'));4});
Head tags¶
We also make use of this super painless, and simple laravel-seo library, to apply best-practice seo tags for each of our posts, and frees our minds for other things over fussing over them manually.
In our ViewServiceProvider, we provide some sane default values:
1class ViewServiceProvider extends ServiceProvider2{3 public function boot(): void4 {5 seo()6 ->withUrl()7 ->site('PE Vermeulen - Software Developer')8 ->tag('author', 'PE Vermeulen')9 ->title(10 default: 'PE Vermeulen - Software Developer',11 modify: fn (string $title) => $title.' | PE Vermeulen'12 )13 ->description(default: "I am a software engineer and music lover. This is where I'll post whatever I'm interested in.")14 ->image(default: fn () => asset('logo.webp'));15 }16}
1class ViewServiceProvider extends ServiceProvider2{3 public function boot(): void4 {5 seo()6 ->withUrl()7 ->site('PE Vermeulen - Software Developer')8 ->tag('author', 'PE Vermeulen')9 ->title(10 default: 'PE Vermeulen - Software Developer',11 modify: fn (string $title) => $title.' | PE Vermeulen'12 )13 ->description(default: "I am a software engineer and music lover. This is where I'll post whatever I'm interested in.")14 ->image(default: fn () => asset('logo.webp'));15 }16}
Self-healing url slugs¶
The url for this page must look something like this -
https://pevermeulen.com/how-this-site-was-made-23
Go ahead and try and mangle the end segment of this url, only taking care not to change the number at the end.
For example:
https://pevermeulen.com/how-thsitas-e-23
You would find you just get redirected back to the same page.
This is made possible through our HealUrl middleware and Post model route resolver override, that pretty much only cares about the number at the end, and searches for our post based on that, and not by the entire slug:
1class HealUrl2{3 public function handle(Request $request, Closure $next): Response4 {5 $path = $request->path();67 $postId = last(explode('-', $path));89 $post = Post::findOrFail($postId);1011 $trueUrl = $post->urlSlug();1213 if ($trueUrl !== $path) {14 $trueUrl = url()->query($trueUrl, $request->query());1516 return redirect($trueUrl, 301);17 }1819 return $next($request);20 }21}
1class HealUrl2{3 public function handle(Request $request, Closure $next): Response4 {5 $path = $request->path();67 $postId = last(explode('-', $path));89 $post = Post::findOrFail($postId);1011 $trueUrl = $post->urlSlug();1213 if ($trueUrl !== $path) {14 $trueUrl = url()->query($trueUrl, $request->query());1516 return redirect($trueUrl, 301);17 }1819 return $next($request);20 }21}
1#[ObservedBy(PostObserver::class)]2class Post extends Model3{
4 protected $casts = [5 'frontmatter' => Frontmatter::class,6 ];78 /** @return BelongsToMany<\App\Models\Tag> */9 public function tags(): BelongsToMany
10 {11 return $this->belongsToMany(Tag::class);12 }1314 protected static function booted(): void
15 {16 /** @param Builder<\App\Models\Post> $builder */17 static::addGlobalScope('published', function (Builder $builder) {18 $builder->orderBy('updated_at', 'desc');19 });20 }2122 public function resolveRouteBinding($value, $field = null): ?Model23 {24 $postId = last(explode('-', $value));2526 return parent::resolveRouteBinding($postId, $field);27 }
1#[ObservedBy(PostObserver::class)]2class Post extends Model3{
4 protected $casts = [5 'frontmatter' => Frontmatter::class,6 ];78 /** @return BelongsToMany<\App\Models\Tag> */9 public function tags(): BelongsToMany
10 {11 return $this->belongsToMany(Tag::class);12 }1314 protected static function booted(): void
15 {16 /** @param Builder<\App\Models\Post> $builder */17 static::addGlobalScope('published', function (Builder $builder) {18 $builder->orderBy('updated_at', 'desc');19 });20 }2122 public function resolveRouteBinding($value, $field = null): ?Model23 {24 $postId = last(explode('-', $value));2526 return parent::resolveRouteBinding($postId, $field);27 }
Optimizing our database¶
Feature flag bitmask¶
We currently have two optional post features that, when enabled, change/add some things during the markdown -> html conversion process.
These features are embedded videos, and code snippets:
1enum PostFeature: int2{3 case VIDEO = 0b00000001;4 case CODE = 0b00000010;56 public static function collect(): Collection7 {8 return collect(self::cases());9 }10}
1enum PostFeature: int2{3 case VIDEO = 0b00000001;4 case CODE = 0b00000010;56 public static function collect(): Collection7 {8 return collect(self::cases());9 }10}
We initially opted for a boolean table column per feature, but dropped that in favour of a bitmask, for futureproofing:
1return new class extends Migration2{3 public function up(): void4 {5 Schema::table('posts', function (Blueprint $table) {6 $table->unsignedTinyInteger('features')->default(0);7 $table->dropColumn(['contains_video', 'contains_code']);8 });9 }10};
1return new class extends Migration2{3 public function up(): void4 {5 Schema::table('posts', function (Blueprint $table) {6 $table->unsignedTinyInteger('features')->default(0);7 $table->dropColumn(['contains_video', 'contains_code']);8 });9 }10};
1#[ObservedBy(PostObserver::class)]2class Post extends Model3{4 public function features(): Attribute5 {6 return Attribute::make(7 get: function (int $sum): Collection {8 return PostFeature::collect()9 ->filter(function (PostFeature $feature) use ($sum) {10 return $feature->value & $sum;11 })12 ->values();13 },14 set: function (array $values): int {15 return Collection::wrap($values)->sum('value');16 }17 );18 }19}
1#[ObservedBy(PostObserver::class)]2class Post extends Model3{4 public function features(): Attribute5 {6 return Attribute::make(7 get: function (int $sum): Collection {8 return PostFeature::collect()9 ->filter(function (PostFeature $feature) use ($sum) {10 return $feature->value & $sum;11 })12 ->values();13 },14 set: function (array $values): int {15 return Collection::wrap($values)->sum('value');16 }17 );18 }19}
Now, we are able to store our two existing feature flags, as well as six additional feature flags that may be implemented in the future, in a single 8 bit unsigned integer column!
In Conclusion¶
I hope you enjoyed learning about this site!
It is my bedtime, and there are some things I didn't quite get to, but I will as always return to my post and update things a bit and flesh things out down the line.
I'll leave you with what this post looks like on my screen before I send it out into the interwebs:
1---2author: PE Vermeulen3title: How this site was made4description: A look at the inner workings of my personal site, and a showcase of the current features.5tags:6- programming7- portfolio8---910# How this site was made1112This website has been one of my _favourite_ sideprojects thusfar, and although I13have a lot of things left to implement, I wanted to showcase some of it's14current bells and whistles, explain it's inner workings, and how I use it.1516## The Initial Idea1718I wanted a personal blog site that was19- easy to publish to,20- looked pretty, without much effort to make it so,21- allowed for nice looking code snippet examples,22- was very speedy and performant (fast page loads),23- was a joy to navigate and use.2425## The Post Lifecycle2627Let's look at what happens from the initial writing of the post, up to28ultimately seeing it on a page such as this one.2930### Writing the post3132I write my blog posts in markdown, which to those unfamiliar, looks something33like this:3435# How this site was made3637This website has been one of my _favourite_ sidepro...3839## The Initial Idea4041I wanted a personal blog si...42- easy to publish to,43- looked pretty nice, without mu...44- ...4546Doing all your formatting in this kind of fashion, means you can move really47fast compared to something fancier like a word document.4849After I am done writing (this post being no exception), I would then use my50handy shell script on my local computer to publish the post to my site:5152#!/bin/sh5354# Check if a file was provided55if [ -z "$1" ]; then56 echo "Usage: $(basename "$0") <markdown file>"57 exit 158fi5960# Get the full path to the markdown file61post_file="$(realpath "$1")"6263# Extract the title from the frontmatter64title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')6566# Check if the title is found in the frontmatter67if [ -z "$title" ]; then68 echo "Error: Title is missing in the frontmatter."69 exit 170fi7172# Read API token securely73api_token="$(pass personal/api-tokens/personal-blog)"7475# Read the contents of the markdown file76body=$(cat "$post_file")7778# Make the POST request79curl -L -X POST "https://pevermeulen.com/api/posts" \80 --header "Authorization: Bearer $api_token" \81 --header "Accept: application/json" \82 --data-urlencode "title=$title" \83 --data-urlencode "body=$body"8485I invoke the script like so:86create-blog-post how_this_site_was_made.md8788I just press enter, and the rest is then handled by the site itself!8990### Conversion of markdown to html9192Once a post reaches the store endpoint of the project and is about to be saved93to the database, we generate the html for the post using the excellent94[Commonmark php library](https://commonmark.thephpleague.com/):9596class PostObserver97{98 public function saving(Post $post): void99 {100 $convertedMarkdown = Markdown::convert($post->markdown); // [tl! ~~]101102 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {103 $frontmatter = $convertedMarkdown->getFrontMatter();104 } else {105 throw new FrontmatterMissingException;106 }107108 $html = $convertedMarkdown->getContent(); // [tl! ~~:2]109110 $post->html = $html;111 $post->frontmatter = new Frontmatter( // [tl! collapse:5]112 title: $frontmatter['title'] ?? $post->title,113 description: $frontmatter['description'],114 tags: $frontmatter['tags'],115 author: $frontmatter['author'] ?? 'PE Vermeulen',116 );117118 $features = [];119120 if (Str::contains($html, 'lite-youtube')) {121 $features[] = PostFeature::VIDEO;122 }123124 if (Str::contains($html, '<pre>')) {125 $features[] = PostFeature::CODE;126 }127128 $post->features = $features;129 }130131Using this, we can, among other things, hook into the parsing and rendering132processes of the html conversion and generate cool stuff, like a table of133contents based on the page's headings, a shout out on the bottom of the page134for [torchlight](https://torchlight.dev/), who provides syntax highlighting for our code snippets, if135our page does in fact contain code snippets, etc.136137I wanted the html to immediately be available on page request, instead of doing138the markdown conversion every single time, so the solution was to simply store139the html in a table column.140141### Displaying the post to the user142143You might at this point be wondering how the site looks the way it does, if144most of the rendered html must be classless elements.145146Our saviour here was classless [picocss](https://picocss.com/docs/classless), which essentially gives us a good147enough style language for the site, by way of mostly element selectors.148149Our markdown to html converter leaves us with a naked, classless, html body150which is pretty much ripe to apply [picocss](https://picocss.com/docs/classless) on top of. Very little CSS was151written for this project by hand, making style maintenance a breeze.152153Pico also promotes the use of symantic html tags, which help a lot with154accessibility and search engine optimization, which we will take a look at next.155156## SEO157158If, before I worked on this project, you had told me I would end up finding159SEO interesting, I would not have believed you. It was, to my surprise, a160really fun rabbit hole to dive into, it turns out.161162### Sitemap163164For our [sitemap](https://pevermeulen.com/sitemap.xml), we make use of this awesome sitemap generator made by the165folks at Spatie - [laravel-sitemap](https://github.com/spatie/laravel-sitemap).166167It runs on a daily cron schedule, and assists search engines with the indexing168of our site:169170Artisan::command('sitemap:generate', function () {171 SitemapGenerator::create(config('app.url'))172 ->writeToFile(public_path('sitemap.xml'));173});174175### Head tags176177We also make use of this super painless, and simple [laravel-seo](https://github.com/archtechx/laravel-seo) library,178to apply best-practice seo tags for each of our posts, and frees our minds for179other things over fussing over them manually.180181In our ViewServiceProvider, we provide some sane default values:182183class ViewServiceProvider extends ServiceProvider184{185 public function boot(): void186 {187 seo()188 ->withUrl()189 ->site('PE Vermeulen - Software Developer')190 ->tag('author', 'PE Vermeulen')191 ->title(192 default: 'PE Vermeulen - Software Developer',193 modify: fn (string $title) => $title.' | PE Vermeulen'194 )195 ->description(default: "I am a software engineer and music lover. This is where I'll post whatever I'm interested in.")196 ->image(default: fn () => asset('logo.webp'));197 }198}199200### Self-healing url slugs201202The url for this page must look something like this -203https://pevermeulen.com/how-this-site-was-made-23204205Go ahead and try and mangle the end segment of this url, only taking care not206to change the number at the end.207208For example:209https://pevermeulen.com/how-thsitas-e-23210211You would find you just get redirected back to the same page.212213This is made possible through our HealUrl middleware and Post model route214resolver override, that pretty much only cares about the number at the end, and215searches for our post based on that, and not by the entire slug:216217class HealUrl218{219 public function handle(Request $request, Closure $next): Response220 {221 $path = $request->path();222223 $postId = last(explode('-', $path));224225 $post = Post::findOrFail($postId);226227 $trueUrl = $post->urlSlug();228229 if ($trueUrl !== $path) {230 $trueUrl = url()->query($trueUrl, $request->query());231232 return redirect($trueUrl, 301);233 }234235 return $next($request);236 }237}238239#[ObservedBy(PostObserver::class)]240class Post extends Model241{242 protected $casts = [ // [tl! collapse:2]243 'frontmatter' => Frontmatter::class,244 ];245246 /** @return BelongsToMany<\App\Models\Tag> */247 public function tags(): BelongsToMany248 { // [tl! collapse:2]249 return $this->belongsToMany(Tag::class);250 }251252 protected static function booted(): void253 { // [tl! collapse:5]254 /** @param Builder<\App\Models\Post> $builder */255 static::addGlobalScope('published', function (Builder $builder) {256 $builder->orderBy('updated_at', 'desc');257 });258 }259260 public function resolveRouteBinding($value, $field = null): ?Model // [tl! ~~:5]261 {262 $postId = last(explode('-', $value));263264 return parent::resolveRouteBinding($postId, $field);265 }266267## Optimizing our database268269### Feature flag bitmask270271We currently have two optional post features that, when enabled, change/add272some things during the markdown -> html conversion process.273274These features are embedded videos, and code snippets:275276enum PostFeature: int277{278 case VIDEO = 0b00000001; // [tl! ~~:1]279 case CODE = 0b00000010;280281 public static function collect(): Collection282 {283 return collect(self::cases());284 }285}286287We initially opted for a boolean table column per feature, but dropped that in288favour of a bitmask, for futureproofing:289290return new class extends Migration291{292 public function up(): void293 {294 Schema::table('posts', function (Blueprint $table) {295 $table->unsignedTinyInteger('features')->default(0);296 $table->dropColumn(['contains_video', 'contains_code']);297 });298 }299};300301#[ObservedBy(PostObserver::class)]302class Post extends Model303{304 public function features(): Attribute305 {306 return Attribute::make(307 get: function (int $sum): Collection {308 return PostFeature::collect()309 ->filter(function (PostFeature $feature) use ($sum) {310 return $feature->value & $sum;311 })312 ->values();313 },314 set: function (array $values): int {315 return Collection::wrap($values)->sum('value');316 }317 );318 }319}320321Now, we are able to store our two existing feature flags, as well as six322additional feature flags that may be implemented in the future, in a single3238 bit unsigned integer column!324325## In Conclusion326327I hope you enjoyed learning about this site!328329It is my bedtime, and there are some things I didn't quite get to, but I will330as always return to my post and update things a bit and flesh things out down331the line.332333I'll leave you with what this post looks like on my screen before I send it out334into the interwebs:335336Were you expecting some kind of infinite mirror here?337338Take care!
1---2author: PE Vermeulen3title: How this site was made4description: A look at the inner workings of my personal site, and a showcase of the current features.5tags:6- programming7- portfolio8---910# How this site was made1112This website has been one of my _favourite_ sideprojects thusfar, and although I13have a lot of things left to implement, I wanted to showcase some of it's14current bells and whistles, explain it's inner workings, and how I use it.1516## The Initial Idea1718I wanted a personal blog site that was19- easy to publish to,20- looked pretty, without much effort to make it so,21- allowed for nice looking code snippet examples,22- was very speedy and performant (fast page loads),23- was a joy to navigate and use.2425## The Post Lifecycle2627Let's look at what happens from the initial writing of the post, up to28ultimately seeing it on a page such as this one.2930### Writing the post3132I write my blog posts in markdown, which to those unfamiliar, looks something33like this:3435# How this site was made3637This website has been one of my _favourite_ sidepro...3839## The Initial Idea4041I wanted a personal blog si...42- easy to publish to,43- looked pretty nice, without mu...44- ...4546Doing all your formatting in this kind of fashion, means you can move really47fast compared to something fancier like a word document.4849After I am done writing (this post being no exception), I would then use my50handy shell script on my local computer to publish the post to my site:5152#!/bin/sh5354# Check if a file was provided55if [ -z "$1" ]; then56 echo "Usage: $(basename "$0") <markdown file>"57 exit 158fi5960# Get the full path to the markdown file61post_file="$(realpath "$1")"6263# Extract the title from the frontmatter64title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')6566# Check if the title is found in the frontmatter67if [ -z "$title" ]; then68 echo "Error: Title is missing in the frontmatter."69 exit 170fi7172# Read API token securely73api_token="$(pass personal/api-tokens/personal-blog)"7475# Read the contents of the markdown file76body=$(cat "$post_file")7778# Make the POST request79curl -L -X POST "https://pevermeulen.com/api/posts" \80 --header "Authorization: Bearer $api_token" \81 --header "Accept: application/json" \82 --data-urlencode "title=$title" \83 --data-urlencode "body=$body"8485I invoke the script like so:86create-blog-post how_this_site_was_made.md8788I just press enter, and the rest is then handled by the site itself!8990### Conversion of markdown to html9192Once a post reaches the store endpoint of the project and is about to be saved93to the database, we generate the html for the post using the excellent94[Commonmark php library](https://commonmark.thephpleague.com/):9596class PostObserver97{98 public function saving(Post $post): void99 {100 $convertedMarkdown = Markdown::convert($post->markdown); // [tl! ~~]101102 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {103 $frontmatter = $convertedMarkdown->getFrontMatter();104 } else {105 throw new FrontmatterMissingException;106 }107108 $html = $convertedMarkdown->getContent(); // [tl! ~~:2]109110 $post->html = $html;111 $post->frontmatter = new Frontmatter( // [tl! collapse:5]112 title: $frontmatter['title'] ?? $post->title,113 description: $frontmatter['description'],114 tags: $frontmatter['tags'],115 author: $frontmatter['author'] ?? 'PE Vermeulen',116 );117118 $features = [];119120 if (Str::contains($html, 'lite-youtube')) {121 $features[] = PostFeature::VIDEO;122 }123124 if (Str::contains($html, '<pre>')) {125 $features[] = PostFeature::CODE;126 }127128 $post->features = $features;129 }130131Using this, we can, among other things, hook into the parsing and rendering132processes of the html conversion and generate cool stuff, like a table of133contents based on the page's headings, a shout out on the bottom of the page134for [torchlight](https://torchlight.dev/), who provides syntax highlighting for our code snippets, if135our page does in fact contain code snippets, etc.136137I wanted the html to immediately be available on page request, instead of doing138the markdown conversion every single time, so the solution was to simply store139the html in a table column.140141### Displaying the post to the user142143You might at this point be wondering how the site looks the way it does, if144most of the rendered html must be classless elements.145146Our saviour here was classless [picocss](https://picocss.com/docs/classless), which essentially gives us a good147enough style language for the site, by way of mostly element selectors.148149Our markdown to html converter leaves us with a naked, classless, html body150which is pretty much ripe to apply [picocss](https://picocss.com/docs/classless) on top of. Very little CSS was151written for this project by hand, making style maintenance a breeze.152153Pico also promotes the use of symantic html tags, which help a lot with154accessibility and search engine optimization, which we will take a look at next.155156## SEO157158If, before I worked on this project, you had told me I would end up finding159SEO interesting, I would not have believed you. It was, to my surprise, a160really fun rabbit hole to dive into, it turns out.161162### Sitemap163164For our [sitemap](https://pevermeulen.com/sitemap.xml), we make use of this awesome sitemap generator made by the165folks at Spatie - [laravel-sitemap](https://github.com/spatie/laravel-sitemap).166167It runs on a daily cron schedule, and assists search engines with the indexing168of our site:169170Artisan::command('sitemap:generate', function () {171 SitemapGenerator::create(config('app.url'))172 ->writeToFile(public_path('sitemap.xml'));173});174175### Head tags176177We also make use of this super painless, and simple [laravel-seo](https://github.com/archtechx/laravel-seo) library,178to apply best-practice seo tags for each of our posts, and frees our minds for179other things over fussing over them manually.180181In our ViewServiceProvider, we provide some sane default values:182183class ViewServiceProvider extends ServiceProvider184{185 public function boot(): void186 {187 seo()188 ->withUrl()189 ->site('PE Vermeulen - Software Developer')190 ->tag('author', 'PE Vermeulen')191 ->title(192 default: 'PE Vermeulen - Software Developer',193 modify: fn (string $title) => $title.' | PE Vermeulen'194 )195 ->description(default: "I am a software engineer and music lover. This is where I'll post whatever I'm interested in.")196 ->image(default: fn () => asset('logo.webp'));197 }198}199200### Self-healing url slugs201202The url for this page must look something like this -203https://pevermeulen.com/how-this-site-was-made-23204205Go ahead and try and mangle the end segment of this url, only taking care not206to change the number at the end.207208For example:209https://pevermeulen.com/how-thsitas-e-23210211You would find you just get redirected back to the same page.212213This is made possible through our HealUrl middleware and Post model route214resolver override, that pretty much only cares about the number at the end, and215searches for our post based on that, and not by the entire slug:216217class HealUrl218{219 public function handle(Request $request, Closure $next): Response220 {221 $path = $request->path();222223 $postId = last(explode('-', $path));224225 $post = Post::findOrFail($postId);226227 $trueUrl = $post->urlSlug();228229 if ($trueUrl !== $path) {230 $trueUrl = url()->query($trueUrl, $request->query());231232 return redirect($trueUrl, 301);233 }234235 return $next($request);236 }237}238239#[ObservedBy(PostObserver::class)]240class Post extends Model241{242 protected $casts = [ // [tl! collapse:2]243 'frontmatter' => Frontmatter::class,244 ];245246 /** @return BelongsToMany<\App\Models\Tag> */247 public function tags(): BelongsToMany248 { // [tl! collapse:2]249 return $this->belongsToMany(Tag::class);250 }251252 protected static function booted(): void253 { // [tl! collapse:5]254 /** @param Builder<\App\Models\Post> $builder */255 static::addGlobalScope('published', function (Builder $builder) {256 $builder->orderBy('updated_at', 'desc');257 });258 }259260 public function resolveRouteBinding($value, $field = null): ?Model // [tl! ~~:5]261 {262 $postId = last(explode('-', $value));263264 return parent::resolveRouteBinding($postId, $field);265 }266267## Optimizing our database268269### Feature flag bitmask270271We currently have two optional post features that, when enabled, change/add272some things during the markdown -> html conversion process.273274These features are embedded videos, and code snippets:275276enum PostFeature: int277{278 case VIDEO = 0b00000001; // [tl! ~~:1]279 case CODE = 0b00000010;280281 public static function collect(): Collection282 {283 return collect(self::cases());284 }285}286287We initially opted for a boolean table column per feature, but dropped that in288favour of a bitmask, for futureproofing:289290return new class extends Migration291{292 public function up(): void293 {294 Schema::table('posts', function (Blueprint $table) {295 $table->unsignedTinyInteger('features')->default(0);296 $table->dropColumn(['contains_video', 'contains_code']);297 });298 }299};300301#[ObservedBy(PostObserver::class)]302class Post extends Model303{304 public function features(): Attribute305 {306 return Attribute::make(307 get: function (int $sum): Collection {308 return PostFeature::collect()309 ->filter(function (PostFeature $feature) use ($sum) {310 return $feature->value & $sum;311 })312 ->values();313 },314 set: function (array $values): int {315 return Collection::wrap($values)->sum('value');316 }317 );318 }319}320321Now, we are able to store our two existing feature flags, as well as six322additional feature flags that may be implemented in the future, in a single3238 bit unsigned integer column!324325## In Conclusion326327I hope you enjoyed learning about this site!328329It is my bedtime, and there are some things I didn't quite get to, but I will330as always return to my post and update things a bit and flesh things out down331the line.332333I'll leave you with what this post looks like on my screen before I send it out334into the interwebs:335336Were you expecting some kind of infinite mirror here?337338Take care!
Take care! ;)