fix blog tag clicks, and new post

This commit is contained in:
Firehose Bot 2026-03-19 22:14:19 +00:00
parent 3ffb0883f9
commit f148fe4fcd
10 changed files with 208 additions and 176 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
# Dokku setup (may contain secrets) # Dokku setup (may contain secrets)
dokku-setup.sh dokku-setup.sh
/output/

View File

@ -1,4 +1,4 @@
<div class="space-y-8"> <div class="space-y-8">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a> <a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a>
<.post_show post={@post} /> <.post_show post={@post} base_path={@base_path} />
</div> </div>

View File

@ -4,7 +4,7 @@
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p> <p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
</header> </header>
<.post_index posts={@posts} base_path={@base_path} /> <.post_index posts={@posts} base_path={@base_path} current_tag={@tag} />
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; All posts</a> <a href={@base_path} class="text-sm text-primary hover:underline">&larr; All posts</a>
</div> </div>

View File

@ -1,6 +1,7 @@
%{ %{
title: "Hello World", title: "Hello World",
author: "Firehose Team", author: "Firehose Team",
published: false,
tags: ~w(elixir phoenix), tags: ~w(elixir phoenix),
description: "Our first engineering blog post" description: "Our first engineering blog post"
} }

View File

@ -0,0 +1,13 @@
%{
title: "Coding agent from scratch - a loop with tools, not that complicated",
author: "Willem van den Ende",
published: True,
tags: ~w(llm coding-agent python exercise),
description: "Coding agents are not that complicated. A loop with some tools. I found an interactive tutorial that lets you experience it"
}
---
I had started on a "Write your own coding agent" exercise. Four iterations in, actually. And then I found [Tiny Agents]( https://tinyagents.dev/lesson/agent-loop), a set of interactive exercises that let you experience how agents work, from a simple chat request, through a tool, more tools etc. It has a live graph, that visualises of the flow of data and actions.
It is good fun to play with, it starts simple and builds up. It lets you inspect the messages between the 'agent' loop code and the large language model server (which is just HTTP and some JSON).

View File

@ -0,0 +1,123 @@
defmodule FirehoseWeb.BlogTagsTest do
use FirehoseWeb.ConnCase
defp goto_engineering_tag_page(conn, tag) do
path = "/blog/engineering/tag/#{tag}"
conn = get(conn, path)
body = html_response(conn, 200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Engineering Blog"
body
end
defp goto_releases_tag_page(conn, tag) do
path = "/blog/releases/tag/#{tag}"
conn = get(conn, path)
body = html_response(conn, 200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Release Notes"
body
end
describe "engineering blog tags" do
test "GET /blog/engineering/tag/:tag shows tag page with all posts", %{conn: conn} do
body = goto_engineering_tag_page(conn, "elixir")
assert body =~ "Hello World"
end
test "GET /blog/engineering/tag/:tag page shows filtered posts", %{conn: conn} do
body = goto_engineering_tag_page(conn, "phoenix")
assert body =~ "Hello World"
end
test "GET /blog/engineering/tag/:tag page shows empty list for nonexistent tag", %{
conn: conn
} do
body = get(conn, "/blog/engineering/tag/nonexistent-tag")
assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag")
end
end
describe "release notes blog tags" do
test "GET /blog/releases/tag/:tag shows tag page with all posts", %{conn: conn} do
body = goto_releases_tag_page(conn, "release")
assert body =~ "v0.1.0 Released"
end
test "GET /blog/releases/tag/:tag page shows filtered posts", %{conn: conn} do
body = get(conn, "/blog/releases/tag/nonexistent-tag")
assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag")
end
end
describe "tag URL pattern" do
test "tag URLs follow pattern /blog/:blog_id/tag/:tag for engineering blog", %{conn: conn} do
# Test that the tag route exists and works correctly
conn = get(conn, "/blog/engineering/tag/elixir")
assert html_response(conn, 200) =~ ~s(tagged "elixir")
conn = get(conn, "/blog/engineering/tag/phoenix")
assert html_response(conn, 200) =~ ~s(tagged "phoenix")
end
test "tag URLs follow pattern /blog/:blog_id/tag/:tag for releases blog", %{conn: conn} do
# Test that the tag route exists and works correctly
conn = get(conn, "/blog/releases/tag/release")
assert html_response(conn, 200) =~ ~s(tagged "release")
end
test "nonexistent tags return 200 with empty post list", %{conn: conn} do
conn = get(conn, "/blog/engineering/tag/nonexistent-tag")
assert html_response(conn, 200)
end
end
describe "tag page structure" do
test "tag page has proper layout and back link", %{conn: conn} do
body = goto_engineering_tag_page(conn, "elixir")
assert body =~ "Engineering Blog"
assert body =~ ~s(tagged "elixir")
assert body =~ "All posts"
end
test "release tag page has proper layout and back link", %{conn: conn} do
body = goto_releases_tag_page(conn, "release")
assert body =~ "Release Notes"
assert body =~ ~s(tagged "release")
assert body =~ "All posts"
end
end
describe "clickable tags on index page" do
test "tags are rendered as clickable links on engineering blog index", %{
conn: conn
} do
conn = get(conn, "/blog/engineering")
body = html_response(conn, 200)
# Verify tag links exist with correct href pattern
assert body =~ ~r{href="/blog/engineering/tag/meta"}
assert body =~ ~r{href="/blog/engineering/tag/ai"}
end
test "tags are rendered as clickable links on releases blog index", %{
conn: conn
} do
conn = get(conn, "/blog/releases")
body = html_response(conn, 200)
# Verify tag link exists
assert body =~ ~r{href="/blog/releases/tag/release"}
end
test "tag links have proper styling classes", %{conn: conn} do
conn = get(conn, "/blog/engineering")
body = html_response(conn, 200)
# Verify blogex-tag-link class is present for tag links
assert body =~ ~r{class="[^"]*blogex-tag-link}
end
end
end

View File

@ -35,7 +35,7 @@ defmodule Blogex.Components do
<h2> <h2>
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a> <a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
</h2> </h2>
<.post_meta post={post} /> <.post_meta post={post} base_path={@base_path} />
</header> </header>
<p class="blogex-post-description">{post.description}</p> <p class="blogex-post-description">{post.description}</p>
</article> </article>
@ -49,15 +49,17 @@ defmodule Blogex.Components do
## Attributes ## Attributes
* `:post` - a `%Blogex.Post{}` struct (required) * `:post` - a `%Blogex.Post{}` struct (required)
* `:base_path` - base URL path for tag links (required)
""" """
attr :post, :map, required: true attr :post, :map, required: true
attr :base_path, :string, required: true
def post_show(assigns) do def post_show(assigns) do
~H""" ~H"""
<article class="blogex-post"> <article class="blogex-post">
<header class="blogex-post-header"> <header class="blogex-post-header">
<h1>{@post.title}</h1> <h1>{@post.title}</h1>
<.post_meta post={@post} /> <.post_meta post={@post} base_path={@base_path} />
</header> </header>
<div class="blogex-post-body"> <div class="blogex-post-body">
{Phoenix.HTML.raw(@post.body)} {Phoenix.HTML.raw(@post.body)}
@ -68,8 +70,16 @@ defmodule Blogex.Components do
@doc """ @doc """
Renders post metadata (date, author, tags). Renders post metadata (date, author, tags).
## Attributes
* `:post` - a `%Blogex.Post{}` struct (required)
* `:base_path` - base URL path for tag links (required)
* `:current_tag` - currently selected tag for highlighting (optional)
""" """
attr :post, :map, required: true attr :post, :map, required: true
attr :base_path, :string, required: true
attr :current_tag, :string, default: nil
def post_meta(assigns) do def post_meta(assigns) do
~H""" ~H"""
@ -80,9 +90,13 @@ defmodule Blogex.Components do
<span :if={@post.author} class="blogex-post-author"> <span :if={@post.author} class="blogex-post-author">
by {@post.author} by {@post.author}
</span> </span>
<span :for={tag <- @post.tags} class="blogex-tag"> <a
:for={tag <- @post.tags}
href={"#{@base_path}/tag/#{tag}"}
class={["blogex-tag-link", tag == @current_tag && "blogex-tag-active"]}
>
{tag} {tag}
</span> </a>
</div> </div>
""" """
end end

View File

@ -64,7 +64,7 @@ defmodule Blogex.Layout do
</head> </head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;"> <body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav> <nav><a href={@base_path}>&larr; Back</a></nav>
<.post_show post={@post} /> <.post_show post={@post} base_path={@base_path} />
</body> </body>
</html> </html>
""" """

View File

@ -1,172 +1,7 @@
# Code Context Investigation complete. Found the tag implementation details:
## Files Retrieved **Key Finding**: The `post_meta` component in `/workspaces/firehose/blogex/lib/blogex/components.ex` (lines 83-85) renders tags as plain text without links, while there's already a working `tag_list` component (lines 93-115) that properly creates links with the pattern `href={"#{@base_path}/tag/#{tag}"}`.
List with exact line ranges:
1. `app/test/firehose_web/controllers/blog_test.exs` (lines 1-128) - Comprehensive blog controller tests covering HTML and JSON API endpoints for engineering blog and release notes
2. `app/lib/firehose_web/controllers/blog_controller.ex` (lines 1-79) - Blog controller with pagination, 404 handling, and input validation
3. `app/test/support/conn_case.ex` (lines 1-38) - Test case template for connection tests
4. `app/lib/firehose/blogs/engineering_blog.ex` (lines 1-7) - Engineering blog module configuration
5. `app/lib/firehose/blogs/release_notes.ex` (lines 1-7) - Release notes blog module configuration
## Key Code **Route structure**: `/tag/:tag` in `/workspaces/firehose/blogex/lib/blogex/router.ex` (line 62) handles tag filtering via `blog.posts_by_tag(tag)`.
### Test Organization **Tests exist**: `/workspaces/firehose/app/test/firehose_web/controllers/blog_test.exs` (lines 35-42, 117-122) verify tag page functionality.
```elixir
# Current structure has 4 describe blocks:
describe "engineering blog (HTML)" # 3 tests
describe "input validation" # 5 tests (newly added in last commit)
describe "release notes blog (HTML)" # 2 tests
describe "engineering blog (JSON API)" # 4 tests
describe "release notes blog (JSON API)" # 3 tests
```
### Input Validation Logic (blog_controller.ex, lines 68-76)
```elixir
defp parse_page(nil), do: 1
defp parse_page(str) do
case Integer.parse(str) do
{page, ""} when page > 0 -> page
_ -> 1
end
end
```
### Test Coverage Added in Last Commit
```elixir
describe "input validation" do
test "GET /blog/nonexistent returns 404", %{conn: conn} do
conn = get(conn, "/blog/nonexistent")
assert html_response(conn, 404)
end
test "GET /blog/engineering?page=abc falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=abc")
assert html_response(conn, 200) =~ "Engineering Blog"
end
test "GET /blog/engineering?page=-1 falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=-1")
assert html_response(conn, 200) =~ "Engineering Blog"
end
test "GET /blog/engineering?page=0 falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=0")
assert html_response(conn, 200) =~ "Engineering Blog"
end
test "GET /blog/engineering/nonexistent-post returns 404", %{conn: conn} do
assert_raise Blogex.NotFoundError, fn ->
get(conn, "/blog/engineering/nonexistent-post")
end
end
end
```
## Architecture
The application uses:
- **Blogex** library for blog functionality (engineering blog and release notes)
- **Phoenix** framework for web endpoints
- **ConnCase** test helper for connection testing
- Two blog types: `Firehose.EngineeringBlog` and `Firehose.ReleaseNotes`
- Pagination through `blog.paginate(page)` method
- 404 handling via `Blogex.NotFoundError` exception
## Start Here
Which file to look at first and why:
**Start with `app/lib/firehose_web/controllers/blog_controller.ex`**
Why: This is the central controller that handles all blog requests. Understanding its structure (especially the `parse_page/1` function and `resolve_blog/2` plug) provides context for why the validation tests were added and how input handling works across both HTML and JSON endpoints.
## Code Smells & Refactoring Suggestions
### Smell 1: Repetitive Validation Tests
**Issue**: Four tests for page parameter validation (`page=abc`, `-1`, `0`, and valid values) are highly repetitive with identical assertions.
**Refactoring Suggestion**: Use parameterized tests or test helpers:
```elixir
# Test helper approach
test_page_fallback("page=abc", "abc")
test_page_fallback("page=-1", "-1")
test_page_fallback("page=0", "0")
defp test_page_fallback(query_param, expected_page) do
conn = get(conn, "/blog/engineering?#{query_param}")
assert html_response(conn, 200) =~ "Engineering Blog"
end
```
### Smell 2: Missing Negative Test Coverage
**Issue**: Tests don't verify what happens when invalid blog_id is provided (e.g., `/blog/invalid-blog`).
**Refactoring Suggestion**: Add test for unknown blog:
```elixir
test "GET /blog/unknown returns 404", %{conn: conn} do
conn = get(conn, "/blog/unknown")
assert html_response(conn, 404)
end
```
### Smell 3: Inconsistent Test Naming
**Issue**: Some tests use hyphenated slugs (`v0-1-0`), others use different formats. The naming doesn't clearly indicate what's being tested.
**Refactoring Suggestion**: Standardize naming:
```elixir
# Instead of: "GET /blog/releases/v0-1-0 returns HTML post"
test "GET /blog/releases/:slug returns a release post", %{conn: conn} do
```
### Smell 4: Redundant Layout Assertions
**Issue**: Multiple tests assert the same "firehose" string appears in response, testing layout presence.
**Refactoring Suggestion**: Create a shared test helper:
```elixir
defp assert_has_app_layout(body),
do: assert body =~ "firehose"
# Then in tests: assert_has_app_layout(body)
```
### Smell 5: Test Order Doesn't Follow Flow
**Issue**: Tests are grouped by endpoint but validation tests (which should be first for defensive programming) are in the middle.
**Refactoring Suggestion**: Reorder to follow natural request flow:
1. Input validation (404s, invalid params)
2. Success cases (index, show, tag)
3. Edge cases (pagination, RSS feeds)
### Smell 6: No Test for Controller-Level Error Handling
**Issue**: The controller uses `halt()` in the resolve_blog plug, but there's no test verifying this behavior.
**Refactoring Suggestion**: Add test:
```elixir
test "GET /blog/:blog_id with invalid blog halts request", %{conn: conn} do
conn = get(conn, "/blog/invalid")
assert conn.halted
end
```
### Smell 7: Mixed Response Types Without Clear Separation
**Issue**: HTML tests use `html_response/2`, JSON tests use `json_response/2`, but there's no helper to verify content type before parsing.
**Refactoring Suggestion**: Create response helpers:
```elixir
defp assert_html(conn, status), do: assert html_response(conn, status) != ""
defp assert_json(conn, status), do: assert json_response(conn, status) != %{}
```
### Smell 8: No Test for Concurrent Requests or Edge Cases
**Issue**: Missing tests for:
- Empty page parameter (`?page=`)
- Very large page numbers
- Special characters in slug/tag parameters
**Refactoring Suggestion**: Add edge case tests to validation describe block.
### Overall Recommendations
1. **Extract test helpers** to reduce duplication (especially for page validation)
2. **Standardize test naming** conventions across all blog types
3. **Add positive test** for valid page numbers (currently missing)
4. **Consider property-based testing** for input validation scenarios
5. **Add performance tests** if pagination is used heavily
6. **Create integration tests** that verify end-to-end flows

45
new-post.md Normal file
View File

@ -0,0 +1,45 @@
```mermaid
sequenceDiagram
participant User
participant Engineering as Engineering Folder<br/>(priv/blog/engineering)
participant Blogex as Blogex Library
participant PhoenixApp as Firehose Web App
participant Browser
Note over User,Browser: New Markdown File Flow
User->>Engineering: Create markdown file<br/>(e.g., new-post.md)
Note over Engineering: File appears in directory
Note over Blogex: Blogex reads markdown files at app startup<br/>via config (priv/blog/engineering/**/*.md)
PhoenixApp->>Blogex: Request post index via BlogController<br/>(GET /blog/engineering)
Blogex->>Engineering: Read markdown files from priv/blog/engineering/
Blogex->>Blogex: Parse markdown + frontmatter
Blogex->>Blogex: Create %Blogex.Post{ structs}
Note over Blogex: Blogex renders HTML using its own<br/>templates in blogex/components.ex (post_index, post_show)
PhoenixApp->>PhoenixApp: Render blog_html/index.html.heex (via BlogHTML)
Note over PhoenixApp,Browser: Individual Post Request<br/>(GET /blog/engineering/:slug)
Browser->>PhoenixApp: HTTP GET /blog/engineering/new-post
PhoenixApp->>PhoenixApp: FirehoseWeb.BlogController.show
PhoenixApp->>Blogex: Get post by slug
Blogex->>Engineering: Read markdown file
Blogex->>Blogex: Parse and return %Blogex.Post{}
Note over Blogex: Blogex renders show_page for individual posts
PhoenixApp->>PhoenixApp: Render blog_html/show.html.heex (via BlogHTML)
PhoenixApp->>PhoenixApp: Apply FirehoseWeb.Layouts.app layout
PhoenixApp->>PhoenixApp: Wrap with FirehoseWeb.Layouts.root layout
Note over PhoenixApp: Layout provides:<br/>- Navbar (Engineering/Releases/QWAN)<br/>- Theme toggle<br/>- Global CSS (app.css with Tailwind/daisyUI)<br/>- Footer/flash messages
PhoenixApp->>Browser: Return full HTML page
Browser->>Browser: Render page with app styling
```