Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Context

Shared domain language for this repo. Keep terms here stable; code should match.

## Glossary

### Blog: "post" vs "article"

The blog uses two nouns for what looks like one thing. The split is intentional —
don't try to collapse it.

- **post** — the identity/data noun. The `BlogPost` model, the `{post}` route
parameter, `$post`/`$posts` variables sourced from the model, and the
`blog.post-card` / `blog.post-grid` components.
- **article** — the reader-facing view-model noun. The assembled, render-ready
array a page hands to the presentation component, the `blog.article` component
itself, and all human-visible copy ("Back to articles", "Read article",
"Latest articles").

### Blog detail routing

`pages/blog/show.blade.php` is bound by **route-model binding**, not a manual
lookup:

- The route is `/blog/{post}` and `BlogPost::getRouteKeyName()` returns `slug`,
so Laravel resolves the `{post}` segment against the `slug` column.
- `mount(BlogPost $post)` receives the already-resolved model (a missing slug
404s before `mount` runs). An explicit `abort_unless($post->is_published, 404)`
keeps unpublished posts hidden, since binding does not apply the `published`
scope.
- The page assembles `public array $article` from that model and renders
`<livewire:blog.article :article="$article" />`.

**Why the view-model isn't also called `$post`:** Livewire binds a route
parameter to any public property of the same name
(`setParametersToMatchingProperties`). A `public array $post` alongside the
`{post}` route param would make Livewire assign the slug string into the array
property — a `TypeError` and a 500 (covered by
`Tests\Feature\ExampleTest::test_blog_article_route_renders_seeded_content_model_post`).
`post` is the bound identity; `article` is the rendered view-model.

Rule of thumb: a slug, route, or model is a **post**; a rendered view-model or
anything a visitor reads is an **article**.
9 changes: 7 additions & 2 deletions app/Console/Commands/EvolveLintCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ protected function checkStaticRouteCollisions(array $artifactRoutes, Router $rou
continue;
}

$expectedUri = ltrim($route['route'], '/');
$expectedUri = $this->normalizeRouteUri((string) $route['route']);
$colliders = collect($router->getRoutes())
->filter(fn ($registered): bool => $registered->getName() === $route['route_name'])
->reject(fn ($registered): bool => $registered->uri() === $expectedUri || '/'.$registered->uri() === $route['route']);
->reject(fn ($registered): bool => $this->normalizeRouteUri($registered->uri()) === $expectedUri);

if ($colliders->isNotEmpty()) {
$findings[] = [
Expand All @@ -99,6 +99,11 @@ protected function checkStaticRouteCollisions(array $artifactRoutes, Router $rou
return $findings;
}

protected function normalizeRouteUri(string $uri): string
{
return trim($uri, '/');
}

protected function checkUnknownMiddleware(array $artifactRoutes, Router $router): array
{
$registered = array_merge(
Expand Down
54 changes: 54 additions & 0 deletions app/Models/BlogPost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class BlogPost extends Model
{
protected $fillable = [
'icon',
'title',
'slug',
'summary',
'position',
'is_published',
];

protected static function booted(): void
{
static::saving(function (BlogPost $post): void {
if (blank($post->slug)) {
$post->slug = Str::slug($post->title);
}
});
}

protected function casts(): array
{
return [
'position' => 'integer',
'is_published' => 'boolean',
];
}

public function getRouteKeyName(): string
{
return 'slug';
}

#[Scope]
protected function ordered(Builder $query): void
{
$query->orderBy('position')->orderBy('id');
}

#[Scope]
protected function published(Builder $query): void
{
$query->where('is_published', true);
}
}
31 changes: 31 additions & 0 deletions database/migrations/2026_05_31_130437_create_blog_posts_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('blog_posts')) {
return;
}

Schema::create('blog_posts', function (Blueprint $table): void {
$table->id();
$table->string('icon', 12);
$table->string('title');
$table->string('slug')->unique();
$table->text('summary');
$table->unsignedInteger('position')->default(0);
$table->boolean('is_published')->default(true);
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('blog_posts');
}
};
86 changes: 86 additions & 0 deletions database/seeders/BlogPostSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace Database\Seeders;

use App\Models\BlogPost;
use Illuminate\Database\Seeder;

class BlogPostSeeder extends Seeder
{
public function run(): void
{
foreach ($this->posts() as $post) {
BlogPost::query()->updateOrCreate(
['slug' => $post['slug']],
$post,
);
}
}

/**
* @return array<int, array{icon: string, title: string, slug: string, summary: string, position: int, is_published: bool}>
*/
private function posts(): array
{
return [
[
'icon' => '01',
'title' => 'Designing with artifacts',
'slug' => 'designing-with-artifacts',
'summary' => <<<'TEXT'
A blog is easier to maintain when its repeated structure is represented as artifacts instead of copied markup. The page should decide what appears, while the layout and snippets decide how recurring pieces behave.

This keeps future changes small. A navigation change belongs in the layout, a card treatment belongs in the snippet, and broad visual decisions belong in the shared stylesheet.

Reusable artifacts are not ceremony; they are the smallest useful boundary for change.
TEXT,
'position' => 1,
'is_published' => true,
],
[
'icon' => '02',
'title' => 'Shipping smaller pages',
'slug' => 'shipping-smaller-pages',
'summary' => <<<'TEXT'
Route files should be readable at a glance. They work best when they assemble data and reusable pieces, rather than becoming the only place where design and content rules live.

For small sites, this can be as simple as one layout, one card snippet, and a page that loops through a compact content model.

Small pages are easier to preview, review, and replace.
TEXT,
'position' => 2,
'is_published' => true,
],
[
'icon' => '03',
'title' => 'Clearer content systems',
'slug' => 'clearer-content-systems',
'summary' => <<<'TEXT'
Content systems do not need to be complex to be useful. They need stable names, obvious ownership, and enough structure for teams to make changes without hunting through unrelated files.

A basic blog can demonstrate that discipline: posts have routes, cards have one shared template, and the page tree makes navigation explicit.

Clarity compounds when the file model matches how people talk about the site.
TEXT,
'position' => 3,
'is_published' => true,
],
[
'icon' => '04',
'title' => 'The power of MCP tools',
'slug' => 'the-power-of-mcp-tools',
'summary' => <<<'TEXT'
MCP tools change where generation work happens. Instead of asking the server to invent, render, and remember every operation, the client can generate intent and call explicit tools that mutate durable state.

That split matters. The server becomes a pure state holder and workflow system: it validates inputs, applies permissions, records artifacts, and exposes the next safe operation. The expensive creative loop stays on the client side, where an agent can spend tokens, compare options, and decide which structured action to take.

This makes the product easier to reason about. A tool call has a schema, a dry-run mode, and a clear write boundary. The server does not need to understand every possible design conversation. It only needs to preserve the source of truth and enforce the rules that keep the workspace coherent.

The result is a better contract between human, agent, and application. The agent can explore freely, but the app remains authoritative about what exists, what can change, and how a change enters the system. MCP turns generation into a client-side capability and leaves the server focused on state, safety, and workflow.
TEXT,
'position' => 4,
'is_published' => true,
],
];
}
}
2 changes: 2 additions & 0 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ public function run(): void
'password' => Hash::make('password'),
],
);

$this->call(BlogPostSeeder::class);
}
}
Loading