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

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 made
2 
3This website has been one of my _favourite_ sidepro...
4 
5## The Initial Idea
6 
7I wanted a personal blog si...
8- easy to publish to,
9- looked pretty nice, without mu...
10- ...
1# How this site was made
2 
3This website has been one of my _favourite_ sidepro...
4 
5## The Initial Idea
6 
7I 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/sh
2 
3# Check if a file was provided
4if [ -z "$1" ]; then
5 echo "Usage: $(basename "$0") <markdown file>"
6 exit 1
7fi
8 
9# Get the full path to the markdown file
10post_file="$(realpath "$1")"
11 
12# Extract the title from the frontmatter
13title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')
14 
15# Check if the title is found in the frontmatter
16if [ -z "$title" ]; then
17 echo "Error: Title is missing in the frontmatter."
18 exit 1
19fi
20 
21# Read API token securely
22api_token="$(pass personal/api-tokens/personal-blog)"
23 
24# Read the contents of the markdown file
25body=$(cat "$post_file")
26 
27# Make the POST request
28curl -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/sh
2 
3# Check if a file was provided
4if [ -z "$1" ]; then
5 echo "Usage: $(basename "$0") <markdown file>"
6 exit 1
7fi
8 
9# Get the full path to the markdown file
10post_file="$(realpath "$1")"
11 
12# Extract the title from the frontmatter
13title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')
14 
15# Check if the title is found in the frontmatter
16if [ -z "$title" ]; then
17 echo "Error: Title is missing in the frontmatter."
18 exit 1
19fi
20 
21# Read API token securely
22api_token="$(pass personal/api-tokens/personal-blog)"
23 
24# Read the contents of the markdown file
25body=$(cat "$post_file")
26 
27# Make the POST request
28curl -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 PostObserver
2{
3 public function saving(Post $post): void
4 {
5 $convertedMarkdown = Markdown::convert($post->markdown);
6 
7 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {
8 $frontmatter = $convertedMarkdown->getFrontMatter();
9 } else {
10 throw new FrontmatterMissingException;
11 }
12 
13 $html = $convertedMarkdown->getContent();
14 
15 $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 );
22 
23 $features = [];
24 
25 if (Str::contains($html, 'lite-youtube')) {
26 $features[] = PostFeature::VIDEO;
27 }
28 
29 if (Str::contains($html, '<pre>')) {
30 $features[] = PostFeature::CODE;
31 }
32 
33 $post->features = $features;
34 }
1class PostObserver
2{
3 public function saving(Post $post): void
4 {
5 $convertedMarkdown = Markdown::convert($post->markdown);
6 
7 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {
8 $frontmatter = $convertedMarkdown->getFrontMatter();
9 } else {
10 throw new FrontmatterMissingException;
11 }
12 
13 $html = $convertedMarkdown->getContent();
14 
15 $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 );
22 
23 $features = [];
24 
25 if (Str::contains($html, 'lite-youtube')) {
26 $features[] = PostFeature::VIDEO;
27 }
28 
29 if (Str::contains($html, '<pre>')) {
30 $features[] = PostFeature::CODE;
31 }
32 
33 $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 ServiceProvider
2{
3 public function boot(): void
4 {
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 ServiceProvider
2{
3 public function boot(): void
4 {
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 HealUrl
2{
3 public function handle(Request $request, Closure $next): Response
4 {
5 $path = $request->path();
6 
7 $postId = last(explode('-', $path));
8 
9 $post = Post::findOrFail($postId);
10 
11 $trueUrl = $post->urlSlug();
12 
13 if ($trueUrl !== $path) {
14 $trueUrl = url()->query($trueUrl, $request->query());
15 
16 return redirect($trueUrl, 301);
17 }
18 
19 return $next($request);
20 }
21}
1class HealUrl
2{
3 public function handle(Request $request, Closure $next): Response
4 {
5 $path = $request->path();
6 
7 $postId = last(explode('-', $path));
8 
9 $post = Post::findOrFail($postId);
10 
11 $trueUrl = $post->urlSlug();
12 
13 if ($trueUrl !== $path) {
14 $trueUrl = url()->query($trueUrl, $request->query());
15 
16 return redirect($trueUrl, 301);
17 }
18 
19 return $next($request);
20 }
21}
1#[ObservedBy(PostObserver::class)]
2class Post extends Model
3{
4 protected $casts = [ ...
5 'frontmatter' => Frontmatter::class,
6 ];
7 
8 /** @return BelongsToMany<\App\Models\Tag> */
9 public function tags(): BelongsToMany
10 { ...
11 return $this->belongsToMany(Tag::class);
12 }
13 
14 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 }
21 
22 public function resolveRouteBinding($value, $field = null): ?Model
23 {
24 $postId = last(explode('-', $value));
25 
26 return parent::resolveRouteBinding($postId, $field);
27 }
1#[ObservedBy(PostObserver::class)]
2class Post extends Model
3{
4 protected $casts = [ ...
5 'frontmatter' => Frontmatter::class,
6 ];
7 
8 /** @return BelongsToMany<\App\Models\Tag> */
9 public function tags(): BelongsToMany
10 { ...
11 return $this->belongsToMany(Tag::class);
12 }
13 
14 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 }
21 
22 public function resolveRouteBinding($value, $field = null): ?Model
23 {
24 $postId = last(explode('-', $value));
25 
26 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: int
2{
3 case VIDEO = 0b00000001;
4 case CODE = 0b00000010;
5 
6 public static function collect(): Collection
7 {
8 return collect(self::cases());
9 }
10}
1enum PostFeature: int
2{
3 case VIDEO = 0b00000001;
4 case CODE = 0b00000010;
5 
6 public static function collect(): Collection
7 {
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 Migration
2{
3 public function up(): void
4 {
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 Migration
2{
3 public function up(): void
4 {
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 Model
3{
4 public function features(): Attribute
5 {
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 Model
3{
4 public function features(): Attribute
5 {
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 Vermeulen
3title: How this site was made
4description: A look at the inner workings of my personal site, and a showcase of the current features.
5tags:
6- programming
7- portfolio
8---
9 
10# How this site was made
11 
12This website has been one of my _favourite_ sideprojects thusfar, and although I
13have a lot of things left to implement, I wanted to showcase some of it's
14current bells and whistles, explain it's inner workings, and how I use it.
15 
16## The Initial Idea
17 
18I wanted a personal blog site that was
19- 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.
24 
25## The Post Lifecycle
26 
27Let's look at what happens from the initial writing of the post, up to
28ultimately seeing it on a page such as this one.
29 
30### Writing the post
31 
32I write my blog posts in markdown, which to those unfamiliar, looks something
33like this:
34 
35# How this site was made
36 
37This website has been one of my _favourite_ sidepro...
38 
39## The Initial Idea
40 
41I wanted a personal blog si...
42- easy to publish to,
43- looked pretty nice, without mu...
44- ...
45 
46Doing all your formatting in this kind of fashion, means you can move really
47fast compared to something fancier like a word document.
48 
49After I am done writing (this post being no exception), I would then use my
50handy shell script on my local computer to publish the post to my site:
51 
52#!/bin/sh
53 
54# Check if a file was provided
55if [ -z "$1" ]; then
56 echo "Usage: $(basename "$0") <markdown file>"
57 exit 1
58fi
59 
60# Get the full path to the markdown file
61post_file="$(realpath "$1")"
62 
63# Extract the title from the frontmatter
64title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')
65 
66# Check if the title is found in the frontmatter
67if [ -z "$title" ]; then
68 echo "Error: Title is missing in the frontmatter."
69 exit 1
70fi
71 
72# Read API token securely
73api_token="$(pass personal/api-tokens/personal-blog)"
74 
75# Read the contents of the markdown file
76body=$(cat "$post_file")
77 
78# Make the POST request
79curl -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"
84 
85I invoke the script like so:
86create-blog-post how_this_site_was_made.md
87 
88I just press enter, and the rest is then handled by the site itself!
89 
90### Conversion of markdown to html
91 
92Once a post reaches the store endpoint of the project and is about to be saved
93to the database, we generate the html for the post using the excellent
94[Commonmark php library](https://commonmark.thephpleague.com/):
95 
96class PostObserver
97{
98 public function saving(Post $post): void
99 {
100 $convertedMarkdown = Markdown::convert($post->markdown); // [tl! ~~]
101 
102 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {
103 $frontmatter = $convertedMarkdown->getFrontMatter();
104 } else {
105 throw new FrontmatterMissingException;
106 }
107 
108 $html = $convertedMarkdown->getContent(); // [tl! ~~:2]
109 
110 $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 );
117 
118 $features = [];
119 
120 if (Str::contains($html, 'lite-youtube')) {
121 $features[] = PostFeature::VIDEO;
122 }
123 
124 if (Str::contains($html, '<pre>')) {
125 $features[] = PostFeature::CODE;
126 }
127 
128 $post->features = $features;
129 }
130 
131Using this, we can, among other things, hook into the parsing and rendering
132processes of the html conversion and generate cool stuff, like a table of
133contents based on the page's headings, a shout out on the bottom of the page
134for [torchlight](https://torchlight.dev/), who provides syntax highlighting for our code snippets, if
135our page does in fact contain code snippets, etc.
136 
137I wanted the html to immediately be available on page request, instead of doing
138the markdown conversion every single time, so the solution was to simply store
139the html in a table column.
140 
141### Displaying the post to the user
142 
143You might at this point be wondering how the site looks the way it does, if
144most of the rendered html must be classless elements.
145 
146Our saviour here was classless [picocss](https://picocss.com/docs/classless), which essentially gives us a good
147enough style language for the site, by way of mostly element selectors.
148 
149Our markdown to html converter leaves us with a naked, classless, html body
150which is pretty much ripe to apply [picocss](https://picocss.com/docs/classless) on top of. Very little CSS was
151written for this project by hand, making style maintenance a breeze.
152 
153Pico also promotes the use of symantic html tags, which help a lot with
154accessibility and search engine optimization, which we will take a look at next.
155 
156## SEO
157 
158If, before I worked on this project, you had told me I would end up finding
159SEO interesting, I would not have believed you. It was, to my surprise, a
160really fun rabbit hole to dive into, it turns out.
161 
162### Sitemap
163 
164For our [sitemap](https://pevermeulen.com/sitemap.xml), we make use of this awesome sitemap generator made by the
165folks at Spatie - [laravel-sitemap](https://github.com/spatie/laravel-sitemap).
166 
167It runs on a daily cron schedule, and assists search engines with the indexing
168of our site:
169 
170Artisan::command('sitemap:generate', function () {
171 SitemapGenerator::create(config('app.url'))
172 ->writeToFile(public_path('sitemap.xml'));
173});
174 
175### Head tags
176 
177We 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 for
179other things over fussing over them manually.
180 
181In our ViewServiceProvider, we provide some sane default values:
182 
183class ViewServiceProvider extends ServiceProvider
184{
185 public function boot(): void
186 {
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}
199 
200### Self-healing url slugs
201 
202The url for this page must look something like this -
203https://pevermeulen.com/how-this-site-was-made-23
204 
205Go ahead and try and mangle the end segment of this url, only taking care not
206to change the number at the end.
207 
208For example:
209https://pevermeulen.com/how-thsitas-e-23
210 
211You would find you just get redirected back to the same page.
212 
213This is made possible through our HealUrl middleware and Post model route
214resolver override, that pretty much only cares about the number at the end, and
215searches for our post based on that, and not by the entire slug:
216 
217class HealUrl
218{
219 public function handle(Request $request, Closure $next): Response
220 {
221 $path = $request->path();
222 
223 $postId = last(explode('-', $path));
224 
225 $post = Post::findOrFail($postId);
226 
227 $trueUrl = $post->urlSlug();
228 
229 if ($trueUrl !== $path) {
230 $trueUrl = url()->query($trueUrl, $request->query());
231 
232 return redirect($trueUrl, 301);
233 }
234 
235 return $next($request);
236 }
237}
238 
239#[ObservedBy(PostObserver::class)]
240class Post extends Model
241{
242 protected $casts = [ // [tl! collapse:2]
243 'frontmatter' => Frontmatter::class,
244 ];
245 
246 /** @return BelongsToMany<\App\Models\Tag> */
247 public function tags(): BelongsToMany
248 { // [tl! collapse:2]
249 return $this->belongsToMany(Tag::class);
250 }
251 
252 protected static function booted(): void
253 { // [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 }
259 
260 public function resolveRouteBinding($value, $field = null): ?Model // [tl! ~~:5]
261 {
262 $postId = last(explode('-', $value));
263 
264 return parent::resolveRouteBinding($postId, $field);
265 }
266 
267## Optimizing our database
268 
269### Feature flag bitmask
270 
271We currently have two optional post features that, when enabled, change/add
272some things during the markdown -> html conversion process.
273 
274These features are embedded videos, and code snippets:
275 
276enum PostFeature: int
277{
278 case VIDEO = 0b00000001; // [tl! ~~:1]
279 case CODE = 0b00000010;
280 
281 public static function collect(): Collection
282 {
283 return collect(self::cases());
284 }
285}
286 
287We initially opted for a boolean table column per feature, but dropped that in
288favour of a bitmask, for futureproofing:
289 
290return new class extends Migration
291{
292 public function up(): void
293 {
294 Schema::table('posts', function (Blueprint $table) {
295 $table->unsignedTinyInteger('features')->default(0);
296 $table->dropColumn(['contains_video', 'contains_code']);
297 });
298 }
299};
300 
301#[ObservedBy(PostObserver::class)]
302class Post extends Model
303{
304 public function features(): Attribute
305 {
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}
320 
321Now, we are able to store our two existing feature flags, as well as six
322additional feature flags that may be implemented in the future, in a single
3238 bit unsigned integer column!
324 
325## In Conclusion
326 
327I hope you enjoyed learning about this site!
328 
329It is my bedtime, and there are some things I didn't quite get to, but I will
330as always return to my post and update things a bit and flesh things out down
331the line.
332 
333I'll leave you with what this post looks like on my screen before I send it out
334into the interwebs:
335 
336Were you expecting some kind of infinite mirror here?
337 
338Take care!
1---
2author: PE Vermeulen
3title: How this site was made
4description: A look at the inner workings of my personal site, and a showcase of the current features.
5tags:
6- programming
7- portfolio
8---
9 
10# How this site was made
11 
12This website has been one of my _favourite_ sideprojects thusfar, and although I
13have a lot of things left to implement, I wanted to showcase some of it's
14current bells and whistles, explain it's inner workings, and how I use it.
15 
16## The Initial Idea
17 
18I wanted a personal blog site that was
19- 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.
24 
25## The Post Lifecycle
26 
27Let's look at what happens from the initial writing of the post, up to
28ultimately seeing it on a page such as this one.
29 
30### Writing the post
31 
32I write my blog posts in markdown, which to those unfamiliar, looks something
33like this:
34 
35# How this site was made
36 
37This website has been one of my _favourite_ sidepro...
38 
39## The Initial Idea
40 
41I wanted a personal blog si...
42- easy to publish to,
43- looked pretty nice, without mu...
44- ...
45 
46Doing all your formatting in this kind of fashion, means you can move really
47fast compared to something fancier like a word document.
48 
49After I am done writing (this post being no exception), I would then use my
50handy shell script on my local computer to publish the post to my site:
51 
52#!/bin/sh
53 
54# Check if a file was provided
55if [ -z "$1" ]; then
56 echo "Usage: $(basename "$0") <markdown file>"
57 exit 1
58fi
59 
60# Get the full path to the markdown file
61post_file="$(realpath "$1")"
62 
63# Extract the title from the frontmatter
64title=$(head -n 5 "$post_file" | sed -n 's/^title:[[:space:]]*\(.*\)$/\1/p')
65 
66# Check if the title is found in the frontmatter
67if [ -z "$title" ]; then
68 echo "Error: Title is missing in the frontmatter."
69 exit 1
70fi
71 
72# Read API token securely
73api_token="$(pass personal/api-tokens/personal-blog)"
74 
75# Read the contents of the markdown file
76body=$(cat "$post_file")
77 
78# Make the POST request
79curl -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"
84 
85I invoke the script like so:
86create-blog-post how_this_site_was_made.md
87 
88I just press enter, and the rest is then handled by the site itself!
89 
90### Conversion of markdown to html
91 
92Once a post reaches the store endpoint of the project and is about to be saved
93to the database, we generate the html for the post using the excellent
94[Commonmark php library](https://commonmark.thephpleague.com/):
95 
96class PostObserver
97{
98 public function saving(Post $post): void
99 {
100 $convertedMarkdown = Markdown::convert($post->markdown); // [tl! ~~]
101 
102 if ($convertedMarkdown instanceof RenderedContentWithFrontMatter) {
103 $frontmatter = $convertedMarkdown->getFrontMatter();
104 } else {
105 throw new FrontmatterMissingException;
106 }
107 
108 $html = $convertedMarkdown->getContent(); // [tl! ~~:2]
109 
110 $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 );
117 
118 $features = [];
119 
120 if (Str::contains($html, 'lite-youtube')) {
121 $features[] = PostFeature::VIDEO;
122 }
123 
124 if (Str::contains($html, '<pre>')) {
125 $features[] = PostFeature::CODE;
126 }
127 
128 $post->features = $features;
129 }
130 
131Using this, we can, among other things, hook into the parsing and rendering
132processes of the html conversion and generate cool stuff, like a table of
133contents based on the page's headings, a shout out on the bottom of the page
134for [torchlight](https://torchlight.dev/), who provides syntax highlighting for our code snippets, if
135our page does in fact contain code snippets, etc.
136 
137I wanted the html to immediately be available on page request, instead of doing
138the markdown conversion every single time, so the solution was to simply store
139the html in a table column.
140 
141### Displaying the post to the user
142 
143You might at this point be wondering how the site looks the way it does, if
144most of the rendered html must be classless elements.
145 
146Our saviour here was classless [picocss](https://picocss.com/docs/classless), which essentially gives us a good
147enough style language for the site, by way of mostly element selectors.
148 
149Our markdown to html converter leaves us with a naked, classless, html body
150which is pretty much ripe to apply [picocss](https://picocss.com/docs/classless) on top of. Very little CSS was
151written for this project by hand, making style maintenance a breeze.
152 
153Pico also promotes the use of symantic html tags, which help a lot with
154accessibility and search engine optimization, which we will take a look at next.
155 
156## SEO
157 
158If, before I worked on this project, you had told me I would end up finding
159SEO interesting, I would not have believed you. It was, to my surprise, a
160really fun rabbit hole to dive into, it turns out.
161 
162### Sitemap
163 
164For our [sitemap](https://pevermeulen.com/sitemap.xml), we make use of this awesome sitemap generator made by the
165folks at Spatie - [laravel-sitemap](https://github.com/spatie/laravel-sitemap).
166 
167It runs on a daily cron schedule, and assists search engines with the indexing
168of our site:
169 
170Artisan::command('sitemap:generate', function () {
171 SitemapGenerator::create(config('app.url'))
172 ->writeToFile(public_path('sitemap.xml'));
173});
174 
175### Head tags
176 
177We 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 for
179other things over fussing over them manually.
180 
181In our ViewServiceProvider, we provide some sane default values:
182 
183class ViewServiceProvider extends ServiceProvider
184{
185 public function boot(): void
186 {
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}
199 
200### Self-healing url slugs
201 
202The url for this page must look something like this -
203https://pevermeulen.com/how-this-site-was-made-23
204 
205Go ahead and try and mangle the end segment of this url, only taking care not
206to change the number at the end.
207 
208For example:
209https://pevermeulen.com/how-thsitas-e-23
210 
211You would find you just get redirected back to the same page.
212 
213This is made possible through our HealUrl middleware and Post model route
214resolver override, that pretty much only cares about the number at the end, and
215searches for our post based on that, and not by the entire slug:
216 
217class HealUrl
218{
219 public function handle(Request $request, Closure $next): Response
220 {
221 $path = $request->path();
222 
223 $postId = last(explode('-', $path));
224 
225 $post = Post::findOrFail($postId);
226 
227 $trueUrl = $post->urlSlug();
228 
229 if ($trueUrl !== $path) {
230 $trueUrl = url()->query($trueUrl, $request->query());
231 
232 return redirect($trueUrl, 301);
233 }
234 
235 return $next($request);
236 }
237}
238 
239#[ObservedBy(PostObserver::class)]
240class Post extends Model
241{
242 protected $casts = [ // [tl! collapse:2]
243 'frontmatter' => Frontmatter::class,
244 ];
245 
246 /** @return BelongsToMany<\App\Models\Tag> */
247 public function tags(): BelongsToMany
248 { // [tl! collapse:2]
249 return $this->belongsToMany(Tag::class);
250 }
251 
252 protected static function booted(): void
253 { // [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 }
259 
260 public function resolveRouteBinding($value, $field = null): ?Model // [tl! ~~:5]
261 {
262 $postId = last(explode('-', $value));
263 
264 return parent::resolveRouteBinding($postId, $field);
265 }
266 
267## Optimizing our database
268 
269### Feature flag bitmask
270 
271We currently have two optional post features that, when enabled, change/add
272some things during the markdown -> html conversion process.
273 
274These features are embedded videos, and code snippets:
275 
276enum PostFeature: int
277{
278 case VIDEO = 0b00000001; // [tl! ~~:1]
279 case CODE = 0b00000010;
280 
281 public static function collect(): Collection
282 {
283 return collect(self::cases());
284 }
285}
286 
287We initially opted for a boolean table column per feature, but dropped that in
288favour of a bitmask, for futureproofing:
289 
290return new class extends Migration
291{
292 public function up(): void
293 {
294 Schema::table('posts', function (Blueprint $table) {
295 $table->unsignedTinyInteger('features')->default(0);
296 $table->dropColumn(['contains_video', 'contains_code']);
297 });
298 }
299};
300 
301#[ObservedBy(PostObserver::class)]
302class Post extends Model
303{
304 public function features(): Attribute
305 {
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}
320 
321Now, we are able to store our two existing feature flags, as well as six
322additional feature flags that may be implemented in the future, in a single
3238 bit unsigned integer column!
324 
325## In Conclusion
326 
327I hope you enjoyed learning about this site!
328 
329It is my bedtime, and there are some things I didn't quite get to, but I will
330as always return to my post and update things a bit and flesh things out down
331the line.
332 
333I'll leave you with what this post looks like on my screen before I send it out
334into the interwebs:
335 
336Were you expecting some kind of infinite mirror here?
337 
338Take care!

Take care! ;)