Skip to content

Commit f691e49

Browse files
committed
Improve SEO morew
1 parent e20590d commit f691e49

9 files changed

Lines changed: 269 additions & 2 deletions

File tree

lib/jola_dev/llms_txt.ex

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
defmodule JolaDev.LlmsTxt do
2+
@moduledoc """
3+
Generates llms.txt and llms-full.txt content per the llmstxt.org spec.
4+
"""
5+
6+
alias JolaDev.Blog
7+
8+
@host "https://jola.dev"
9+
10+
def generate do
11+
"""
12+
# jola.dev
13+
14+
> Personal website of Johanna Larsson, a software engineer, engineering leader, writer, and speaker with over 14 years of experience building products and leading teams.
15+
16+
## Pages
17+
18+
- [About](#{@host}/about): Background, experience, and expertise
19+
- [Blog](#{@host}/posts): Technical blog posts on software engineering, Elixir, and leadership
20+
- [Projects](#{@host}/projects): Open source projects
21+
- [Talks](#{@host}/talks): Conference presentations and speaking engagements
22+
23+
## Blog Posts
24+
25+
#{post_links()}\
26+
"""
27+
|> String.trim_trailing()
28+
end
29+
30+
def generate_full do
31+
"""
32+
# jola.dev
33+
34+
> Personal website of Johanna Larsson, a software engineer, engineering leader, writer, and speaker with over 14 years of experience building products and leading teams.
35+
36+
## Pages
37+
38+
- [About](#{@host}/about): Background, experience, and expertise
39+
- [Blog](#{@host}/posts): Technical blog posts on software engineering, Elixir, and leadership
40+
- [Projects](#{@host}/projects): Open source projects
41+
- [Talks](#{@host}/talks): Conference presentations and speaking engagements
42+
43+
## Blog Posts
44+
45+
#{post_details()}\
46+
"""
47+
|> String.trim_trailing()
48+
end
49+
50+
defp post_links do
51+
Blog.all_posts()
52+
|> Enum.map_join("\n", fn post ->
53+
"- [#{post.title}](#{@host}/posts/#{post.id})"
54+
end)
55+
end
56+
57+
defp post_details do
58+
Blog.all_posts()
59+
|> Enum.map_join("\n", fn post ->
60+
"- [#{post.title}](#{@host}/posts/#{post.id}): #{post.description}"
61+
end)
62+
end
63+
end

lib/jola_dev_web/components/layouts/root.html.heex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@
6767
rel="stylesheet"
6868
/>
6969
<link rel="icon" type="image/png" href="/images/logo.png" />
70+
<%= for schema <- JolaDevWeb.SEO.json_ld(@conn) do %>
71+
<script type="application/ld+json">
72+
<%= raw(Jason.encode!(schema)) %>
73+
</script>
74+
<% end %>
7075
</head>
7176
<body class="min-h-screen antialiased flex flex-col">
7277
{@inner_content}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule JolaDevWeb.LlmsController do
2+
use JolaDevWeb, :controller
3+
4+
def index(conn, _params) do
5+
conn
6+
|> put_resp_content_type("text/plain")
7+
|> send_resp(200, JolaDev.LlmsTxt.generate())
8+
end
9+
10+
def full(conn, _params) do
11+
conn
12+
|> put_resp_content_type("text/plain")
13+
|> send_resp(200, JolaDev.LlmsTxt.generate_full())
14+
end
15+
end

lib/jola_dev_web/controllers/page_controller.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ defmodule JolaDevWeb.PageController do
1616
render(conn, :about,
1717
page_title: "About | jola.dev",
1818
meta_description:
19-
"About Johanna Larsson — software engineer, engineering leader, writer, and speaker with over 14 years of experience."
19+
"About Johanna Larsson — software engineer, engineering leader, writer, and speaker with over 14 years of experience.",
20+
page_type: :about
2021
)
2122
end
2223

lib/jola_dev_web/helpers/seo.ex

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
defmodule JolaDevWeb.SEO do
2+
@moduledoc """
3+
Generates JSON-LD structured data for search engines and AI systems.
4+
"""
5+
6+
@host "https://jola.dev"
7+
8+
def json_ld(conn) do
9+
[website_schema()] ++ page_schemas(conn)
10+
end
11+
12+
defp website_schema do
13+
%{
14+
"@context" => "https://schema.org",
15+
"@type" => "WebSite",
16+
"name" => "jola.dev",
17+
"url" => @host,
18+
"author" => person_ref()
19+
}
20+
end
21+
22+
defp page_schemas(%{assigns: %{post: post}}) do
23+
[
24+
%{
25+
"@context" => "https://schema.org",
26+
"@type" => "BlogPosting",
27+
"headline" => post.title,
28+
"description" => post.description,
29+
"datePublished" => Date.to_iso8601(post.date),
30+
"author" => person(),
31+
"publisher" => person(),
32+
"mainEntityOfPage" => "#{@host}/posts/#{post.id}",
33+
"image" => "#{@host}/images/og-image.png",
34+
"keywords" => post.tags
35+
}
36+
]
37+
end
38+
39+
defp page_schemas(%{assigns: %{page_type: :about}}) do
40+
[
41+
%{
42+
"@context" => "https://schema.org",
43+
"@type" => "ProfilePage",
44+
"mainEntity" =>
45+
Map.merge(person(), %{
46+
"sameAs" => [
47+
"https://github.com/joladev",
48+
"https://linkedin.com/in/joladev",
49+
"https://bsky.app/profile/jola.dev",
50+
"https://twitter.com/joladev"
51+
]
52+
})
53+
}
54+
]
55+
end
56+
57+
defp page_schemas(_conn), do: []
58+
59+
defp person do
60+
%{
61+
"@type" => "Person",
62+
"@id" => "#{@host}/#person",
63+
"name" => "Johanna Larsson",
64+
"url" => "#{@host}/about",
65+
"jobTitle" => "Software Engineer & Engineering Leader"
66+
}
67+
end
68+
69+
defp person_ref do
70+
%{"@id" => "#{@host}/#person"}
71+
end
72+
end

lib/jola_dev_web/router.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ defmodule JolaDevWeb.Router do
77
plug :fetch_live_flash
88
plug :put_root_layout, html: {JolaDevWeb.Layouts, :root}
99
plug :protect_from_forgery
10-
plug :put_secure_browser_headers
10+
11+
plug :put_secure_browser_headers, %{
12+
"strict-transport-security" => "max-age=63072000; includeSubDomains",
13+
"referrer-policy" => "strict-origin-when-cross-origin",
14+
"permissions-policy" => "camera=(), microphone=(), geolocation=()"
15+
}
1116
end
1217

1318
pipeline :api do
@@ -38,6 +43,11 @@ defmodule JolaDevWeb.Router do
3843
get "/sitemap.xml", SitemapController, :index
3944
end
4045

46+
scope "/", JolaDevWeb do
47+
get "/llms.txt", LlmsController, :index
48+
get "/llms-full.txt", LlmsController, :full
49+
end
50+
4151
# Other scopes may use custom stacks.
4252
# scope "/api", JolaDevWeb do
4353
# pipe_through :api

test/jola_dev/llms_txt_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule JolaDev.LlmsTxtTest do
2+
use ExUnit.Case, async: true
3+
4+
describe "generate/0" do
5+
test "includes site header and pages" do
6+
content = JolaDev.LlmsTxt.generate()
7+
8+
assert content =~ "# jola.dev"
9+
assert content =~ "https://jola.dev/about"
10+
assert content =~ "https://jola.dev/posts"
11+
assert content =~ "https://jola.dev/projects"
12+
assert content =~ "https://jola.dev/talks"
13+
end
14+
15+
test "includes blog post links" do
16+
content = JolaDev.LlmsTxt.generate()
17+
18+
for post <- JolaDev.Blog.all_posts() do
19+
assert content =~ post.title
20+
assert content =~ "https://jola.dev/posts/#{post.id}"
21+
end
22+
end
23+
end
24+
25+
describe "generate_full/0" do
26+
test "includes post descriptions" do
27+
content = JolaDev.LlmsTxt.generate_full()
28+
29+
for post <- JolaDev.Blog.all_posts() do
30+
assert content =~ post.description
31+
end
32+
end
33+
end
34+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule JolaDevWeb.LlmsControllerTest do
2+
use JolaDevWeb.ConnCase, async: true
3+
4+
test "GET /llms.txt returns plain text with site info", %{conn: conn} do
5+
conn = get(conn, "/llms.txt")
6+
7+
assert response_content_type(conn, :text) =~ "text/plain"
8+
body = response(conn, 200)
9+
assert body =~ "# jola.dev"
10+
assert body =~ "https://jola.dev/posts"
11+
end
12+
13+
test "GET /llms-full.txt returns plain text with post descriptions", %{conn: conn} do
14+
conn = get(conn, "/llms-full.txt")
15+
16+
assert response_content_type(conn, :text) =~ "text/plain"
17+
body = response(conn, 200)
18+
assert body =~ "# jola.dev"
19+
20+
post = List.first(JolaDev.Blog.all_posts())
21+
assert body =~ post.description
22+
end
23+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule JolaDevWeb.SEOTest do
2+
use JolaDevWeb.ConnCase, async: true
3+
4+
describe "json_ld/1" do
5+
test "includes WebSite schema on all pages", %{conn: conn} do
6+
conn = get(conn, ~p"/")
7+
schemas = JolaDevWeb.SEO.json_ld(conn)
8+
9+
website = Enum.find(schemas, &(&1["@type"] == "WebSite"))
10+
assert website["name"] == "jola.dev"
11+
assert website["url"] == "https://jola.dev"
12+
end
13+
14+
test "includes BlogPosting schema on blog post pages", %{conn: conn} do
15+
post = List.first(JolaDev.Blog.all_posts())
16+
conn = get(conn, ~p"/posts/#{post.id}")
17+
schemas = JolaDevWeb.SEO.json_ld(conn)
18+
19+
blog_posting = Enum.find(schemas, &(&1["@type"] == "BlogPosting"))
20+
assert blog_posting["headline"] == post.title
21+
assert blog_posting["description"] == post.description
22+
assert blog_posting["datePublished"] == Date.to_iso8601(post.date)
23+
assert blog_posting["author"]["@type"] == "Person"
24+
assert blog_posting["keywords"] == post.tags
25+
end
26+
27+
test "includes ProfilePage schema on about page", %{conn: conn} do
28+
conn = get(conn, ~p"/about")
29+
schemas = JolaDevWeb.SEO.json_ld(conn)
30+
31+
profile = Enum.find(schemas, &(&1["@type"] == "ProfilePage"))
32+
assert profile["mainEntity"]["@type"] == "Person"
33+
assert profile["mainEntity"]["name"] == "Johanna Larsson"
34+
assert is_list(profile["mainEntity"]["sameAs"])
35+
end
36+
37+
test "does not include BlogPosting on non-post pages", %{conn: conn} do
38+
conn = get(conn, ~p"/projects")
39+
schemas = JolaDevWeb.SEO.json_ld(conn)
40+
41+
refute Enum.any?(schemas, &(&1["@type"] == "BlogPosting"))
42+
end
43+
end
44+
end

0 commit comments

Comments
 (0)