fix blog tag clicks, and new post
This commit is contained in:
parent
be4be118a3
commit
671add15bb
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
# Dokku setup (may contain secrets)
|
||||
dokku-setup.sh
|
||||
/output/
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div class="space-y-8">
|
||||
<a href={@base_path} class="text-sm text-primary hover:underline">← Back to posts</a>
|
||||
<.post_show post={@post} />
|
||||
<.post_show post={@post} base_path={@base_path} />
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
|
||||
</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">← All posts</a>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
%{
|
||||
title: "Hello World",
|
||||
author: "Firehose Team",
|
||||
published: false,
|
||||
tags: ~w(elixir phoenix),
|
||||
description: "Our first engineering blog post"
|
||||
}
|
||||
|
||||
13
app/priv/blog/engineering/2026/03-20-llm-simple-play.md
Normal file
13
app/priv/blog/engineering/2026/03-20-llm-simple-play.md
Normal 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).
|
||||
123
app/test/firehose_web/controllers/blog_tags_test.exs
Normal file
123
app/test/firehose_web/controllers/blog_tags_test.exs
Normal 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
|
||||
@ -35,7 +35,7 @@ defmodule Blogex.Components do
|
||||
<h2>
|
||||
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
|
||||
</h2>
|
||||
<.post_meta post={post} />
|
||||
<.post_meta post={post} base_path={@base_path} />
|
||||
</header>
|
||||
<p class="blogex-post-description">{post.description}</p>
|
||||
</article>
|
||||
@ -49,15 +49,17 @@ defmodule Blogex.Components do
|
||||
## Attributes
|
||||
|
||||
* `:post` - a `%Blogex.Post{}` struct (required)
|
||||
* `:base_path` - base URL path for tag links (required)
|
||||
"""
|
||||
attr :post, :map, required: true
|
||||
attr :base_path, :string, required: true
|
||||
|
||||
def post_show(assigns) do
|
||||
~H"""
|
||||
<article class="blogex-post">
|
||||
<header class="blogex-post-header">
|
||||
<h1>{@post.title}</h1>
|
||||
<.post_meta post={@post} />
|
||||
<.post_meta post={@post} base_path={@base_path} />
|
||||
</header>
|
||||
<div class="blogex-post-body">
|
||||
{Phoenix.HTML.raw(@post.body)}
|
||||
@ -68,8 +70,16 @@ defmodule Blogex.Components do
|
||||
|
||||
@doc """
|
||||
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 :base_path, :string, required: true
|
||||
attr :current_tag, :string, default: nil
|
||||
|
||||
def post_meta(assigns) do
|
||||
~H"""
|
||||
@ -80,9 +90,13 @@ defmodule Blogex.Components do
|
||||
<span :if={@post.author} class="blogex-post-author">
|
||||
by {@post.author}
|
||||
</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}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -64,7 +64,7 @@ defmodule Blogex.Layout do
|
||||
</head>
|
||||
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
|
||||
<nav><a href={@base_path}>← Back</a></nav>
|
||||
<.post_show post={@post} />
|
||||
<.post_show post={@post} base_path={@base_path} />
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
173
context.md
173
context.md
@ -1,172 +1,7 @@
|
||||
# Code Context
|
||||
Investigation complete. Found the tag implementation details:
|
||||
|
||||
## Files Retrieved
|
||||
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 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}"}`.
|
||||
|
||||
## 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
|
||||
```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
|
||||
**Tests exist**: `/workspaces/firehose/app/test/firehose_web/controllers/blog_test.exs` (lines 35-42, 117-122) verify tag page functionality.
|
||||
45
new-post.md
Normal file
45
new-post.md
Normal 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
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user