From 4c1be97efc3141977ba2047aabb61bb91c68623e Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sun, 24 May 2026 17:10:26 +0100 Subject: [PATCH] SEO improvements (or not, who knows) --- lib/jola_dev/blog/post.ex | 12 +++- .../controllers/page_controller.ex | 6 +- lib/jola_dev_web/helpers/canonical_url.ex | 2 + lib/jola_dev_web/helpers/seo.ex | 57 ++++++++++++++----- ...8-elixir-string-processing-optimization.md | 2 +- ...05-13-registry-select-2-and-match-specs.md | 2 +- .../06-05-patterns-for-managing-ets-tables.md | 2 +- .../2019/10-18-erlang-queue-module-elixir.md | 2 +- priv/posts/2019/10-19-push-based-genstage.md | 2 +- priv/posts/2020/01-21-announcing-hex-diff.md | 2 +- priv/posts/2020/01-21-building-hex-diff.md | 5 +- .../2025/02-04-distributed-ratelimiter.md | 4 +- ...building-a-blog-with-elixir-and-phoenix.md | 2 +- priv/posts/2026/04-02-dropping-cloudflare.md | 14 +++-- .../2026/05-17-bunnyx-bunny-net-elixir.md | 10 ++-- test/jola_dev/blog_test.exs | 2 +- .../controllers/page_controller_test.exs | 4 +- test/jola_dev_web/helpers/seo_test.exs | 43 ++++++++++++-- .../plugs/request_logger_test.exs | 4 +- 19 files changed, 130 insertions(+), 47 deletions(-) diff --git a/lib/jola_dev/blog/post.ex b/lib/jola_dev/blog/post.ex index 76618e6..8dfcdd6 100644 --- a/lib/jola_dev/blog/post.ex +++ b/lib/jola_dev/blog/post.ex @@ -3,7 +3,17 @@ defmodule JolaDev.Blog.Post do Struct representing a blog post with metadata and content. """ @enforce_keys [:id, :author, :title, :body, :description, :tags, :date] - defstruct [:id, :author, :title, :body, :description, :tags, :date, :last_modified] + defstruct [ + :id, + :author, + :title, + :body, + :description, + :tags, + :date, + :last_modified, + :canonical_url + ] def build(filename, attrs, body) do [year, month_day_id] = diff --git a/lib/jola_dev_web/controllers/page_controller.ex b/lib/jola_dev_web/controllers/page_controller.ex index 8c93537..83befeb 100644 --- a/lib/jola_dev_web/controllers/page_controller.ex +++ b/lib/jola_dev_web/controllers/page_controller.ex @@ -5,7 +5,7 @@ defmodule JolaDevWeb.PageController do recent_posts = Enum.take(JolaDev.Blog.all_posts(), 3) render(conn, :home, - page_title: "Johanna Larsson — Software Engineer & Speaker", + page_title: "Johanna Larsson, Software Engineer and Speaker", meta_description: "Johanna Larsson is a software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams.", recent_posts: recent_posts @@ -14,9 +14,9 @@ defmodule JolaDevWeb.PageController do def about(conn, _params) do render(conn, :about, - page_title: "About | jola.dev", + page_title: "About Johanna Larsson", meta_description: - "About Johanna Larsson — software engineer, engineering leader, writer, and speaker with many years of experience.", + "About Johanna Larsson, software engineer, engineering leader, writer, and speaker with many years of experience.", page_type: :about ) end diff --git a/lib/jola_dev_web/helpers/canonical_url.ex b/lib/jola_dev_web/helpers/canonical_url.ex index ff2dfb8..762e3b6 100644 --- a/lib/jola_dev_web/helpers/canonical_url.ex +++ b/lib/jola_dev_web/helpers/canonical_url.ex @@ -6,6 +6,8 @@ defmodule JolaDevWeb.Helpers.CanonicalUrl do @doc """ Generates a canonical URL for the current request. """ + def canonical_url(%{assigns: %{post: %{canonical_url: url}}}) when is_binary(url), do: url + def canonical_url(conn) do "https://jola.dev#{conn.request_path}" end diff --git a/lib/jola_dev_web/helpers/seo.ex b/lib/jola_dev_web/helpers/seo.ex index 0e21c81..1b7ddf9 100644 --- a/lib/jola_dev_web/helpers/seo.ex +++ b/lib/jola_dev_web/helpers/seo.ex @@ -8,7 +8,7 @@ defmodule JolaDevWeb.Helpers.SEO do @host "https://jola.dev" def json_ld(conn) do - [website_schema()] ++ page_schemas(conn) ++ breadcrumb_schemas(conn) + [website_schema(), person_schema()] ++ page_schemas(conn) ++ breadcrumb_schemas(conn) end defp website_schema do @@ -16,11 +16,14 @@ defmodule JolaDevWeb.Helpers.SEO do "@context" => "https://schema.org", "@type" => "WebSite", "name" => "jola.dev", - "url" => @host, - "author" => person_ref() + "url" => @host } end + defp person_schema do + Map.put(person(), "@context", "https://schema.org") + end + defp page_schemas(%{assigns: %{post: post}}) do url = "#{@host}#{~p"/posts/#{post.id}"}" @@ -48,28 +51,56 @@ defmodule JolaDevWeb.Helpers.SEO do %{ "@context" => "https://schema.org", "@type" => "ProfilePage", - "mainEntity" => - Map.merge(person(), %{ - "sameAs" => [ - "https://github.com/joladev", - "https://linkedin.com/in/joladev", - "https://bsky.app/profile/jola.dev", - "https://twitter.com/joladev" - ] - }) + "mainEntity" => person_ref() + } + ] + end + + defp page_schemas(%{assigns: %{posts: _, tag: _}}), do: [] + + defp page_schemas(%{assigns: %{posts: posts}}) do + [ + %{ + "@context" => "https://schema.org", + "@type" => "Blog", + "@id" => "#{@host}/posts#blog", + "url" => "#{@host}/posts", + "name" => "jola.dev blog", + "author" => person_ref(), + "blogPost" => Enum.map(posts, &blog_post_summary/1) } ] end defp page_schemas(_conn), do: [] + defp blog_post_summary(post) do + url = "#{@host}#{~p"/posts/#{post.id}"}" + + %{ + "@type" => "BlogPosting", + "headline" => post.title, + "url" => url, + "datePublished" => Date.to_iso8601(post.date), + "dateModified" => Date.to_iso8601(post.last_modified), + "image" => "#{@host}#{JolaDev.OGImage.path_for("posts/#{post.id}")}", + "author" => person_ref() + } + end + defp person do %{ "@type" => "Person", "@id" => "#{@host}/#person", "name" => "Johanna Larsson", "url" => "#{@host}#{~p"/about"}", - "jobTitle" => "Software Engineer & Engineering Leader" + "jobTitle" => "Software Engineer & Engineering Leader", + "sameAs" => [ + "https://github.com/joladev", + "https://linkedin.com/in/joladev", + "https://bsky.app/profile/jola.dev", + "https://twitter.com/joladev" + ] } end diff --git a/priv/posts/2019/04-08-elixir-string-processing-optimization.md b/priv/posts/2019/04-08-elixir-string-processing-optimization.md index c06db59..7fd3efd 100644 --- a/priv/posts/2019/04-08-elixir-string-processing-optimization.md +++ b/priv/posts/2019/04-08-elixir-string-processing-optimization.md @@ -43,7 +43,7 @@ Fortunately, Elixir and Erlang have a solution for this: ETS. In the words of th > This module is an interface to the Erlang built-in term storage BIFs. These provide the ability to store very large quantities of data in an Erlang runtime system and to have constant access time to the data. -So here we have something that acts a little bit more like a mutable hash map. There are lots of other reasons why ETS is much cooler than a plan hash map, but I’ll leave that to a future article. For now, let’s just pretend that it’s a hash map. +So here we have something that acts a little bit more like a mutable hash map. There are lots of other reasons why ETS is much cooler than a plain hash map, but I [wrote about that separately](/posts/patterns-for-managing-ets-tables). For now, let’s just pretend that it’s a hash map. It’s started with `:ets.new(:table_name, options)`. You can insert data with `:ets.insert` and read with `:ets.lookup`, but the two operations we’re interested in are `:ets.update_counter` and `:ets.match_object`. Here’s what it looks like if we replace the map with an ETS table. diff --git a/priv/posts/2019/05-13-registry-select-2-and-match-specs.md b/priv/posts/2019/05-13-registry-select-2-and-match-specs.md index 7767b26..0b5ffa7 100644 --- a/priv/posts/2019/05-13-registry-select-2-and-match-specs.md +++ b/priv/posts/2019/05-13-registry-select-2-and-match-specs.md @@ -19,7 +19,7 @@ After bringing this up in the Slack I was encouraged to suggest it on the Elixir ## Registry.select/2 -So, the final version that got merged ended up being based on match specs, giving a close to direct access to the underlying ETS table of the Registry. I’ll grab some examples from the documentation to give an idea of what this means. +So, the final version that got merged ended up being based on match specs, giving a close to direct access to the [underlying ETS table](/posts/patterns-for-managing-ets-tables) of the Registry. I’ll grab some examples from the documentation to give an idea of what this means. Get all registered processes, keys and values. diff --git a/priv/posts/2019/06-05-patterns-for-managing-ets-tables.md b/priv/posts/2019/06-05-patterns-for-managing-ets-tables.md index 3e2b53e..8dcc7f5 100644 --- a/priv/posts/2019/06-05-patterns-for-managing-ets-tables.md +++ b/priv/posts/2019/06-05-patterns-for-managing-ets-tables.md @@ -9,7 +9,7 @@ This article attempts to show some basic patterns for how to use ETS in Elixir o ETS is a really cool tool in the Erlang runtime system, but to understand it you may need a little context. I won’t go too deep into how it works, but even a very short explanation of it starts with a description of the memory management of the BEAM. In short, every single line of Elixir or Erlang runs in a process and each process has its own heap. This provides all kinds of cool properties for your code: including sidestepping problems of shared memory access and making garbage collection cheap by limiting it per process (since the heap is automatically freed by processes that end, sometimes you don’t even necessarily get any overhead from garbage collection). But if every process owns its own heap and is unable to access any other’s, how can they share data? -One way is sending messages, which means copying it from one heap to another. This is what you do if you communicate across processes. Let’s take [Agents](https://elixir-lang.org/getting-started/mix-otp/agent.html) as an example. Each agent stores its own state and accepts requests to get or update that data. Any data you get out of an Agent is copied to the requesting process. There are downsides to storing data in processes though, you are not able to access it concurrently. The process holding the data acts as a bottle-neck. This in and of It also generally keeps its state in a standard immutable Elixir data structure. Immutable data structures are really cool, but as I discussed in [Elixir String Processing Optimization](https://blog.jola.dev/elixir-string-processing-optimization), their performance characteristics don’t make them the appropriate choice for all use cases. +One way is sending messages, which means copying it from one heap to another. This is what you do if you communicate across processes. Let’s take [Agents](https://elixir-lang.org/getting-started/mix-otp/agent.html) as an example. Each agent stores its own state and accepts requests to get or update that data. Any data you get out of an Agent is copied to the requesting process. There are downsides to storing data in processes though, you are not able to access it concurrently. The process holding the data acts as a bottle-neck. This in and of It also generally keeps its state in a standard immutable Elixir data structure. Immutable data structures are really cool, but as I discussed in [Elixir String Processing Optimization](/posts/elixir-string-processing-optimization), their performance characteristics don’t make them the appropriate choice for all use cases. The Erlang runtime system offers ETS precisely to solve those problems (though technically that may not be why they were originally created). It offers extremely performant mutable updates and concurrent reads. Just to give you a ballpark notion, a microbenchmark I made showed ETS as about 25 times faster than [Redis/Redix](https://github.com/whatyouhide/redix), with both reads and writes coming in at a median of 2µs. But don’t trust me, if speed matters to you make sure to measure it yourself! diff --git a/priv/posts/2019/10-18-erlang-queue-module-elixir.md b/priv/posts/2019/10-18-erlang-queue-module-elixir.md index b7a2683..4987af2 100644 --- a/priv/posts/2019/10-18-erlang-queue-module-elixir.md +++ b/priv/posts/2019/10-18-erlang-queue-module-elixir.md @@ -11,7 +11,7 @@ Erlang queues are similar to lists but double-ended, meaning you can efficiently > Queues are double-ended. The mental picture of a queue is a line of people (items) waiting for their turn. The queue front is the end with the item that has waited the longest. The queue rear is the end an item enters when it starts to wait. If instead using the mental picture of a list, the front is called head and the rear is called tail. -A common use case is where you want to temporarily store items and eventually take them back out in the order that you put them in. +A common use case is where you want to temporarily store items and eventually take them back out in the order that you put them in. I use this pattern as the buffer in [Push-based GenStage](/posts/push-based-genstage). Using them can feel a bit awkward, partly because Erlang orders arguments differently than Elixir, and partly because the internal representation of queues is exposed. Note that the docs clearly state that you should treat it as an opaque type. diff --git a/priv/posts/2019/10-19-push-based-genstage.md b/priv/posts/2019/10-19-push-based-genstage.md index 1b27779..7e76944 100644 --- a/priv/posts/2019/10-19-push-based-genstage.md +++ b/priv/posts/2019/10-19-push-based-genstage.md @@ -11,7 +11,7 @@ This blog post is mostly educational, but this pattern can be applied effectivel Let’s take a look at a stripped-down example. We’ll set up a producer that accepts messages from any other process, like a regular GenServer, and distributes them to the consumers. We’ll avoid worrying about `min_demand` and `max_demand` and limit consumers to ask for a single event at a time. This is perfectly reasonable where the work is slow and processing time irregular, eg if they involve making requests to network resources. `GenStage` comes with a built-in buffer, if a producer produces more messages than the consumers can handle, they’re automatically buffered with a maximum of `10_000` items by default. We’re not going to rely on it though, we’ll be buffering ourselves in the producer. This has some interesting benefits in improvements we can build to the producer, including backpressure. That’s a future post though. -First out, we define a module using `GenStage` with `start_link` and `init`, with the latter returning a tuple of a queue and an initial demand of 0. If you haven’t seen `:queue` before, take a look at the [Erlang documentation](http://erlang.org/doc/man/queue.html). In short, it allows us to create an efficient buffer for situations where the producer gets pushed more events than the consumers can handle. +First out, we define a module using `GenStage` with `start_link` and `init`, with the latter returning a tuple of a queue and an initial demand of 0. If you haven't seen :queue before, take a look at [my walkthrough](/posts/erlang-queue-module-elixir) or the [Erlang documentation](http://erlang.org/doc/man/queue.html). In short, it allows us to create an efficient buffer for situations where the producer gets pushed more events than the consumers can handle. ```elixir defmodule Producer do diff --git a/priv/posts/2020/01-21-announcing-hex-diff.md b/priv/posts/2020/01-21-announcing-hex-diff.md index 4181baf..59f5af6 100644 --- a/priv/posts/2020/01-21-announcing-hex-diff.md +++ b/priv/posts/2020/01-21-announcing-hex-diff.md @@ -27,4 +27,4 @@ When it’s easy to work in a secure way, people are more likely to do it. This ## What’s next? -The project is open-source, licensed under Apache 2.0, like Elixir itself. You’ll find it under the `hexpm` organization [on Github](https://github.com/hexpm/diff). Please don’t hesitate to share your ideas for improvements or additions! +The project is open-source, licensed under Apache 2.0, like Elixir itself. You’ll find it under the `hexpm` organization [on Github](https://github.com/hexpm/diff). Please don’t hesitate to share your ideas for improvements or additions! If you're curious about how we built it, I wrote a separate post on [Building Hex Diff](/posts/building-hex-diff). diff --git a/priv/posts/2020/01-21-building-hex-diff.md b/priv/posts/2020/01-21-building-hex-diff.md index 7c8fd17..b0e5621 100644 --- a/priv/posts/2020/01-21-building-hex-diff.md +++ b/priv/posts/2020/01-21-building-hex-diff.md @@ -5,7 +5,8 @@ description: "I wanted to give some insight into the Hex Diff project, how it works, and some issues we ran into on the way." } --- -I wanted to give some insight into the Hex Diff project, how it works, and some issues we ran into on the way. The project is [open-source](https://github.com/hexpm/diff), so feel free to look behind the curtain to see what makes it tick. Some parts of the project originate from my toy version (no longer live), but others were written specifically for this project. We also ended up finding and fixing a bunch of bugs in other projects! + +I wanted to give some insight into the Hex Diff project, how it works, and some issues we ran into on the way. The project is [open-source](https://github.com/hexpm/diff), so feel free to look behind the curtain to see what makes it tick. Some parts of the project originate from my toy version (no longer live), but others were written specifically for this project. We also ended up finding and fixing a bunch of bugs in other projects! This is a companion to [the announcement post](/posts/announcing-hex-diff). ## The Search View @@ -19,7 +20,7 @@ Hex has an API with loads of useful endpoints, including search, but we ended up Really, the only things we need are a list of package names and a list of versions for each of the packages, and we’ll be able to implement our own search. Luckily, `hex_core` also lets you query the CDN fronted registry itself for simple stuff, like getting all package names and versions! This means we can do super cheap requests for this data. -The last thing to consider is how LiveView works. While typing into the input field every single key input turns into an event on the backend. Although you could build throttling into this, the slower you are to react to the events, the less snappy the UI feels. Instead of loading all the repo data from Hex on `mount` into each LV process, or throttling or debouncing events, I went with a different approach. I set up an ETS table to own all the data, and then a process that periodically refreshes it with the latest results from Hex. Now we can query the package name and version data directly from ETS, and not worry about LiveView overloading anything while making the UI super fast. +The last thing to consider is how LiveView works. While typing into the input field every single key input turns into an event on the backend. Although you could build throttling into this, the slower you are to react to the events, the less snappy the UI feels. Instead of loading all the repo data from Hex on `mount` into each LV process, or throttling or debouncing events, I went with a different approach. I set up [an ETS table](/posts/patterns-for-managing-ets-tables) to own all the data, and then a process that periodically refreshes it with the latest results from Hex. Now we can query the package name and version data directly from ETS, and not worry about LiveView overloading anything while making the UI super fast. ## Generating and Rendering Diffs diff --git a/priv/posts/2025/02-04-distributed-ratelimiter.md b/priv/posts/2025/02-04-distributed-ratelimiter.md index 7919b18..e7adfcd 100644 --- a/priv/posts/2025/02-04-distributed-ratelimiter.md +++ b/priv/posts/2025/02-04-distributed-ratelimiter.md @@ -2,7 +2,9 @@ title: "Building a Distributed Rate Limiter in Elixir with HashRing", author: "Johanna Larsson", tags: ~w(disterl), - description: "Using distributed Elixir to upgrade a node local rate limiter" + description: "Using distributed Elixir to upgrade a node local rate limiter", + canonical_url: + "https://blog.appsignal.com/2025/02/04/building-a-distributed-rate-limiter-in-elixir-with-hashring.html" } --- diff --git a/priv/posts/2026/03-24-building-a-blog-with-elixir-and-phoenix.md b/priv/posts/2026/03-24-building-a-blog-with-elixir-and-phoenix.md index a65980c..bcb1ec0 100644 --- a/priv/posts/2026/03-24-building-a-blog-with-elixir-and-phoenix.md +++ b/priv/posts/2026/03-24-building-a-blog-with-elixir-and-phoenix.md @@ -315,4 +315,4 @@ This app is intentionally kept simple but powerful. Everything is set up the way And while you’re at it, why not host it on Hetzner! If you use the [referral link to sign up you get €20 and I get €10](https://hetzner.cloud/?ref=SjrsM8GhyYOl). If you prefer not to use the referral link, here’s a plain link: https://www.hetzner.com/cloud/. Also consider joining me in [sponsoring Dokploy](https://github.com/sponsors/Dokploy). -Source code is available at: https://github.com/joladev/jola.dev. Next up I’ll talk about setting up [bunny.net](https://bunny.net?ref=f0l8865b7g) and a separate post on Dokploy on Hetzner. +Source code is available at: https://github.com/joladev/jola.dev. [Next up](/posts/dropping-cloudflare) I’ll talk about setting up [bunny.net](https://bunny.net?ref=f0l8865b7g) and a separate post on Dokploy on Hetzner. diff --git a/priv/posts/2026/04-02-dropping-cloudflare.md b/priv/posts/2026/04-02-dropping-cloudflare.md index db3ad0c..6ea6c7d 100644 --- a/priv/posts/2026/04-02-dropping-cloudflare.md +++ b/priv/posts/2026/04-02-dropping-cloudflare.md @@ -8,7 +8,7 @@ TL;DR my motivation and experience for moving my blog from Cloudflare to bunny.net -I’ve been a long time Cloudflare user. They offer a solid service that is free for the vast majority of their users, that’s very generous. Their infrastructure is massive and their feature set is undeniably incredible. +I’ve been a long time Cloudflare user. They offer a solid service that is free for the vast majority of their users, that’s very generous. Their infrastructure is massive and their feature set is undeniably incredible. One of my biggest concerns though is around how easily I could become heavily dependent on this one single company that then can decide to cut me off and disable all of my websites, for any arbitrary reason. It’s a single point of failure for the internet. Every Cloudflare outage ends up in the news. And I can’t help but feel that the idea of centralizing the internet into a single US corporation feels off. Not to mention the various scandals that have surrounded them. So I was open to alternatives. @@ -36,7 +36,7 @@ The pull zone is the main mechanism for enabling the CDN for your website. You 1. Fill in the pull zone name. Just make it something meaningful to you, for example the website name. 2. For origin type, select Origin URL. -3. Fill in your Origin URL. This would be the address for directly accessing your server. In my case, it’s the public IP of my server. +3. Fill in your Origin URL. This would be the address for directly accessing your server. In my case, it’s the public IP of my server. 4. If you’re running multiple apps on your server, for example using Dokploy, coolify, or self-hosted PaaSs like that, you’ll want to pass the Host header as well. Here you put in the domain of your app. In my case, that’s jola.dev. 5. For tier, select Standard. 6. Finally you can select your pricing zones. Note that some zones are more expensive, so you can choose to disable them. This just means that people in those areas will get redirected to the closest zone you do have enabled. @@ -50,7 +50,7 @@ Now that you’ve set up the pull zone, it’s time to hook it up to your websit 1. Under “Add a custom hostname” fill in your website domain name. 2. You’ll get a modal with some instructions. You need to follow them to set up the DNS name to point your website to go through the CDN. 3. Go to where you manage domain name and add a CNAME record to point your domain to the given CNAME value in the modal, something like website.b-cdn.net. -4. Once you’ve done that, wait a few minutes to let it propagate, and then click “Verify & Activate SSL”. +4. Once you’ve done that, wait a few minutes to let it propagate, and then click “Verify & Activate SSL”. 5. If it says success, you’re done. Your website is now running through the bunny.net CDN, similar to the Cloudflare orange cloud. ### Configuring caching @@ -61,7 +61,7 @@ If your website is set up to return the appropriate cache headers for each resou Alternatively, if you don’t have cache headers set up, and you don’t want to control that yourself, you can instead enable Smart Cache. This will default to caching typically cached resources like images, CSS, JS files etc, while avoiding caching things like HTML pages. This will work for most cases! -But I wanted to go *faster*. If you’ve read my post about building this website, here’s how I’ve set up my cache headers: I added a new pipeline in the router called `public` and added an extra middleware to it. I technically have everything using this pipeline, but leaving the standard `browser` pipeline that comes out of the box with Phoenix keeps my options open to add authenticated (uncached) pages in the future. +But I wanted to go *faster*. If you've read [my post about building this website](/posts/building-a-blog-with-elixir-and-phoenix), here’s how I’ve set up my cache headers: I added a new pipeline in the router called `public` and added an extra middleware to it. I technically have everything using this pipeline, but leaving the standard `browser` pipeline that comes out of the box with Phoenix keeps my options open to add authenticated (uncached) pages in the future. ```elixir pipeline :public do @@ -70,7 +70,7 @@ But I wanted to go *faster*. If you’ve read my post about building this websit plug :put_secure_browser_headers, @secure_headers plug :put_cdn_cache_header end - + defp put_cdn_cache_header(conn, _opts) do put_resp_header(conn, "cache-control", "public, s-maxage=86400, max-age=0") end @@ -94,7 +94,7 @@ DDoS protection comes out of the box, but we can set some other things up. First Next, go to Caching → General and scroll down. At the bottom of the page you can select Stale Cache: While Origin Offline and While Updating. This means bunny will keep serving cached content even if it is stale, if it can’t reach your origin, and that it will serve stale content while fetching the latest version. Both are nice to haves, nothing you have to enable, but provide a slightly better service to your users! -Next, let’s set up an Edge rule to redirect any requests to our automatically generated pull zone domain to our actual domain, to avoid confusing crawlers. On your pull zone, in the left menu, click Edge rules. +Next, let’s set up an Edge rule to redirect any requests to our automatically generated pull zone domain to our actual domain, to avoid confusing crawlers. On your pull zone, in the left menu, click Edge rules. 1. Add edge rule. 2. Name it “Default domain redirect”. @@ -114,3 +114,5 @@ This post just covers the very basics of getting set up on bunny.net. I haven’ I especially appreciate the great statistics, logs, and metrics you get out of the dashboard. You can even see every single request coming through to help you investigate issues, and clear feedback on what’s getting cached and not. I’m actively moving everything else over and I’m excited for the upcoming S3 compatible storage! You should give [bunny.net](https://bunny.net?ref=f0l8865b7g) a try! + +ps Since writing this I've open-sourced [bunnyx](/posts/bunnyx-bunny-net-elixir), my Elixir client for the bunny.net API. diff --git a/priv/posts/2026/05-17-bunnyx-bunny-net-elixir.md b/priv/posts/2026/05-17-bunnyx-bunny-net-elixir.md index b23eef7..b4e68d9 100644 --- a/priv/posts/2026/05-17-bunnyx-bunny-net-elixir.md +++ b/priv/posts/2026/05-17-bunnyx-bunny-net-elixir.md @@ -8,9 +8,9 @@ [bunny.net](https://bunny.net?ref=f0l8865b7g) is a CDN service provider with lots of extra bells and whistles, most similar to Cloudflare in feature set, but maintained by a European company. Unlike Cloudflare, they’re not free, but they are cheap enough that it doesn’t really matter. Unless you’re making very heavy use of their service, you won’t be paying more than the minimum €1 each month. And as a paying customer, your relationship is a lot different than it is as a non-paying user of Cloudflare. -I’ve been using bunny.net for a lot of things I’ve been building recently, including the landing page and status page feature for [Larm](https://larm.dev), the uptime monitoring project I’ve been working on. As part of that work, I wanted a feature rich client library for interacting with the extensive bunny.net API. +I’ve been using bunny.net for a lot of things I’ve been building recently, including the landing page and status page feature for [Larm](https://larm.dev), the uptime monitoring project I’ve been working on, and I wrote about [migrating my blog from Cloudflare to bunny.net](/posts/dropping-cloudflare) recently. As part of that work, I wanted a feature rich client library for interacting with the extensive bunny.net API. -# Introducing: [bunnyx](https://github.com/joladev/bunnyx) +## Introducing: [bunnyx](https://github.com/joladev/bunnyx) This is not the first Elixir library for interacting with the bunny API. I ended up rolling my own because I wanted something that was flexible enough to fit into my system design. The version that I built into Larm wasn’t as feature rich as what I’ve ended up open sourcing here, but once I had something going, I just got into the flow of testing through the API endpoints end to end, and documenting and verifying each one. And suddenly I had a whole feature complete library. @@ -18,7 +18,7 @@ This is not the first Elixir library for interacting with the bunny API. I ended Additionally I invested a lot of time and energy into the test suite. Apart from extensive unit test coverage, I built a series of Livebook runbooks that you can feed an API key into and execute each and every request scenario, even running through the whole book to automatically create, read, update, and delete, resources. This means you can easily verify the behavior of `bunnyx` against the actual bunny API. -# Quickstart +## Quickstart ```elixir # Before running this, you'll need: @@ -68,7 +68,7 @@ defmodule MyApp.Bunny do end ``` -# Feature set +## Feature set **Main API (`Bunnyx.new/1`)** @@ -88,7 +88,7 @@ end - **S3** (`Bunnyx.S3`): PUT, GET, DELETE, HEAD, COPY, ListObjectsV2, multipart uploads - **Stream** (`Bunnyx.Stream`): video CRUD, upload, fetch, collections, captions, thumbnails, re-encode, transcription, smart actions, analytics, oEmbed -# What next +## What next First of all I want to give a huge shoutout to Wojtek Mach for setting an incredible example for the Elixir community on how to design libraries. Req is one of my favorite reference repos and I keep coming back to it and discovering new gems. diff --git a/test/jola_dev/blog_test.exs b/test/jola_dev/blog_test.exs index ffefa69..4cfeb77 100644 --- a/test/jola_dev/blog_test.exs +++ b/test/jola_dev/blog_test.exs @@ -94,7 +94,7 @@ defmodule JolaDev.BlogTest do Enum.count(p.tags, &(&1 in post.tags)) end) - # Non-increasing — higher counts come first. + # Non-increasing, higher counts come first. assert counts == Enum.sort(counts, :desc) end diff --git a/test/jola_dev_web/controllers/page_controller_test.exs b/test/jola_dev_web/controllers/page_controller_test.exs index 77c6fc2..fa8b765 100644 --- a/test/jola_dev_web/controllers/page_controller_test.exs +++ b/test/jola_dev_web/controllers/page_controller_test.exs @@ -6,7 +6,7 @@ defmodule JolaDevWeb.PageControllerTest do conn = get(conn, ~p"/") assert html_response(conn, 200) =~ "jola.dev" - assert conn.assigns.page_title == "Johanna Larsson — Software Engineer & Speaker" + assert conn.assigns.page_title == "Johanna Larsson, Software Engineer and Speaker" assert is_list(conn.assigns.recent_posts) assert length(conn.assigns.recent_posts) == 3 end @@ -17,7 +17,7 @@ defmodule JolaDevWeb.PageControllerTest do conn = get(conn, ~p"/about") assert html_response(conn, 200) - assert conn.assigns.page_title == "About | jola.dev" + assert conn.assigns.page_title == "About Johanna Larsson" end end diff --git a/test/jola_dev_web/helpers/seo_test.exs b/test/jola_dev_web/helpers/seo_test.exs index 62203e3..d960386 100644 --- a/test/jola_dev_web/helpers/seo_test.exs +++ b/test/jola_dev_web/helpers/seo_test.exs @@ -11,6 +11,18 @@ defmodule JolaDevWeb.Helpers.SEOTest do website = Enum.find(schemas, &(&1["@type"] == "WebSite")) assert website["name"] == "jola.dev" assert website["url"] == "https://jola.dev" + refute Map.has_key?(website, "author") + end + + test "includes Person schema on all pages", %{conn: conn} do + for path <- [~p"/", ~p"/posts", ~p"/projects", ~p"/talks", ~p"/about"] do + schemas = SEO.json_ld(get(conn, path)) + person = Enum.find(schemas, &(&1["@type"] == "Person")) + + assert person["@id"] == "https://jola.dev/#person", "missing Person on #{path}" + assert person["name"] == "Johanna Larsson" + assert is_list(person["sameAs"]) + end end test "includes BlogPosting schema on blog post pages", %{conn: conn} do @@ -30,14 +42,12 @@ defmodule JolaDevWeb.Helpers.SEOTest do assert blog_posting["keywords"] == post.tags end - test "includes ProfilePage schema on about page", %{conn: conn} do + test "includes ProfilePage schema on about page referencing the Person", %{conn: conn} do conn = get(conn, ~p"/about") schemas = SEO.json_ld(conn) profile = Enum.find(schemas, &(&1["@type"] == "ProfilePage")) - assert profile["mainEntity"]["@type"] == "Person" - assert profile["mainEntity"]["name"] == "Johanna Larsson" - assert is_list(profile["mainEntity"]["sameAs"]) + assert profile["mainEntity"] == %{"@id" => "https://jola.dev/#person"} end test "does not include BlogPosting on non-post pages", %{conn: conn} do @@ -47,6 +57,31 @@ defmodule JolaDevWeb.Helpers.SEOTest do refute Enum.any?(schemas, &(&1["@type"] == "BlogPosting")) end + test "includes Blog schema with blogPost list on /posts", %{conn: conn} do + conn = get(conn, ~p"/posts") + schemas = SEO.json_ld(conn) + + blog = Enum.find(schemas, &(&1["@type"] == "Blog")) + assert blog["@id"] == "https://jola.dev/posts#blog" + assert blog["url"] == "https://jola.dev/posts" + assert blog["author"] == %{"@id" => "https://jola.dev/#person"} + assert is_list(blog["blogPost"]) + assert length(blog["blogPost"]) == length(JolaDev.Blog.all_posts()) + + first = hd(blog["blogPost"]) + assert first["@type"] == "BlogPosting" + assert is_binary(first["headline"]) + assert String.starts_with?(first["url"], "https://jola.dev/posts/") + assert first["author"] == %{"@id" => "https://jola.dev/#person"} + end + + test "does not include Blog schema on tag pages", %{conn: conn} do + conn = get(conn, ~p"/posts/tag/elixir") + schemas = SEO.json_ld(conn) + + refute Enum.any?(schemas, &(&1["@type"] == "Blog")) + end + test "omits BreadcrumbList on the home page", %{conn: conn} do conn = get(conn, ~p"/") schemas = SEO.json_ld(conn) diff --git a/test/jola_dev_web/plugs/request_logger_test.exs b/test/jola_dev_web/plugs/request_logger_test.exs index f119b47..1e2a137 100644 --- a/test/jola_dev_web/plugs/request_logger_test.exs +++ b/test/jola_dev_web/plugs/request_logger_test.exs @@ -44,7 +44,7 @@ defmodule JolaDevWeb.Plugs.RequestLoggerTest do IO.iodata_to_binary(msg) end - describe "call/2 — request log" do + describe "call/2 request log" do test "emits structured request fields on entry", %{conn: conn} do conn |> Map.put(:method, "POST") @@ -93,7 +93,7 @@ defmodule JolaDevWeb.Plugs.RequestLoggerTest do end end - describe "call/2 — response log via register_before_send" do + describe "call/2 response log via register_before_send" do test "emits structured response fields when send_resp fires", %{conn: conn} do conn |> call_plug()