diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6a5931c --- /dev/null +++ b/CONTEXT.md @@ -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 + ``. + +**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**. diff --git a/app/Console/Commands/EvolveLintCommand.php b/app/Console/Commands/EvolveLintCommand.php index f735a90..51d81a0 100644 --- a/app/Console/Commands/EvolveLintCommand.php +++ b/app/Console/Commands/EvolveLintCommand.php @@ -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[] = [ @@ -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( diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php new file mode 100644 index 0000000..022e2c1 --- /dev/null +++ b/app/Models/BlogPost.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/database/migrations/2026_05_31_130437_create_blog_posts_table.php b/database/migrations/2026_05_31_130437_create_blog_posts_table.php new file mode 100644 index 0000000..6b82904 --- /dev/null +++ b/database/migrations/2026_05_31_130437_create_blog_posts_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/seeders/BlogPostSeeder.php b/database/seeders/BlogPostSeeder.php new file mode 100644 index 0000000..b96b303 --- /dev/null +++ b/database/seeders/BlogPostSeeder.php @@ -0,0 +1,86 @@ +posts() as $post) { + BlogPost::query()->updateOrCreate( + ['slug' => $post['slug']], + $post, + ); + } + } + + /** + * @return array + */ + 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, + ], + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e625f32..be077b4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -25,5 +25,7 @@ public function run(): void 'password' => Hash::make('password'), ], ); + + $this->call(BlogPostSeeder::class); } } diff --git a/resources/css/blog.css b/resources/css/blog.css new file mode 100644 index 0000000..4f4381e --- /dev/null +++ b/resources/css/blog.css @@ -0,0 +1,178 @@ +:root { + --blog-ink: #1b1f24; + --blog-muted: #5c6670; + --blog-line: #d9e1e8; + --blog-paper: #fbfcfd; + --blog-accent: #0f766e; + --blog-accent-soft: #d7f2ed; +} + +.blog-shell { + min-height: 100vh; + background: var(--blog-paper); + color: var(--blog-ink); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.blog-container { + width: min(100% - 2rem, 1080px); + margin-inline: auto; +} + +.blog-header { + border-bottom: 1px solid var(--blog-line); + background: rgba(251, 252, 253, 0.92); +} + +.blog-header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 72px; +} + +.blog-brand { + color: var(--blog-ink); + font-size: 1.05rem; + font-weight: 720; + text-decoration: none; +} + +.blog-nav { + display: flex; + align-items: center; + gap: 1rem; +} + +.blog-nav a { + color: var(--blog-muted); + font-size: 0.95rem; + font-weight: 600; + text-decoration: none; +} + +.blog-nav a:hover { + color: var(--blog-accent); +} + +.blog-main { + padding-block: 3rem 4rem; +} + +.blog-eyebrow { + color: var(--blog-accent); + font-size: 0.78rem; + font-weight: 760; + letter-spacing: 0; + text-transform: uppercase; +} + +.blog-hero { + display: grid; + gap: 1rem; + max-width: 760px; + padding-block: 1rem 2rem; +} + +.blog-hero h1 { + margin: 0; + font-size: clamp(2.2rem, 6vw, 4.5rem); + line-height: 0.98; + letter-spacing: 0; +} + +.blog-hero p, +.blog-prose p { + color: var(--blog-muted); + font-size: 1.05rem; + line-height: 1.75; +} + +.blog-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.blog-card { + display: grid; + gap: 1rem; + min-height: 260px; + padding: 1.25rem; + border: 1px solid var(--blog-line); + border-radius: 8px; + background: #ffffff; + color: inherit; + text-decoration: none; +} + +.blog-card:hover { + border-color: color-mix(in srgb, var(--blog-accent) 45%, var(--blog-line)); +} + +.blog-card-meta, +.blog-article-meta { + color: var(--blog-muted); + font-size: 0.9rem; + font-weight: 620; +} + +.blog-card h2 { + margin: 0; + font-size: 1.35rem; + line-height: 1.2; + letter-spacing: 0; +} + +.blog-card p { + margin: 0; + color: var(--blog-muted); + line-height: 1.6; +} + +.blog-card-link, +.blog-back-link { + align-self: end; + color: var(--blog-accent); + font-weight: 720; +} + +.blog-article { + display: grid; + gap: 1.5rem; + max-width: 760px; +} + +.blog-article h1 { + margin: 0; + font-size: clamp(2rem, 5vw, 3.75rem); + line-height: 1; + letter-spacing: 0; +} + +.blog-prose { + display: grid; + gap: 1rem; +} + +.blog-callout { + padding: 1rem; + border-left: 4px solid var(--blog-accent); + background: var(--blog-accent-soft); + color: #164e49; + font-weight: 650; +} + +@media (max-width: 640px) { + .blog-header-inner { + align-items: flex-start; + flex-direction: column; + padding-block: 1rem; + } + + .blog-main { + padding-block: 2rem 3rem; + } +} diff --git a/resources/css/layouts/blog.css b/resources/css/layouts/blog.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resources/css/layouts/blog.css @@ -0,0 +1 @@ + diff --git a/resources/evolve/manifest.json b/resources/evolve/manifest.json index a12e1b5..7012d3b 100644 --- a/resources/evolve/manifest.json +++ b/resources/evolve/manifest.json @@ -1,5 +1,11 @@ { - "styles": [], + "styles": [ + { + "id": "blog", + "name": "Blog", + "path": "resources/css/blog.css" + } + ], "components": [ { "id": "app-logo-icon", @@ -48,6 +54,30 @@ "name": "Placeholder Pattern", "path": "resources/views/components/placeholder-pattern.blade.php", "usage": "" + }, + { + "id": "blog/hero", + "name": "Blog Hero", + "path": "resources/views/components/blog/hero.blade.php", + "usage": "" + }, + { + "id": "blog/post-card", + "name": "Blog Post Card", + "path": "resources/views/components/blog/post-card.blade.php", + "usage": "" + }, + { + "id": "blog/post-grid", + "name": "Blog Post Grid", + "path": "resources/views/components/blog/post-grid.blade.php", + "usage": "" + }, + { + "id": "blog/article", + "name": "Blog Article", + "path": "resources/views/components/blog/article.blade.php", + "usage": "" } ], "forms": [], @@ -93,6 +123,12 @@ "name": "Split", "path": "resources/views/layouts/auth/split.blade.php", "usage": "" + }, + { + "id": "blog", + "name": "Blog", + "path": "resources/views/layouts/blog.blade.php", + "usage": "" } ], "pages": [ @@ -298,6 +334,30 @@ "order": 15, "usage": "", "depth": 0 + }, + { + "id": "blog", + "name": "Blog", + "path": "resources/views/pages/blog.blade.php", + "route": "/", + "route_name": "home", + "middleware": [], + "parent_id": "", + "order": 20, + "usage": "", + "depth": 0 + }, + { + "id": "blog/show", + "name": "Blog Article", + "path": "resources/views/pages/blog/show.blade.php", + "route": "/blog/{post}", + "route_name": "blog.show", + "middleware": [], + "parent_id": "blog", + "order": 21, + "usage": "", + "depth": 1 } ], "snippets": [], @@ -382,8 +442,8 @@ "name": "Welcome", "path": "resources/views/welcome.blade.php", "usage": "@include('welcome')", - "route": "/", - "route_name": "home", + "route": "/welcome", + "route_name": "welcome", "middleware": [] } ] diff --git a/resources/views/components/blog/article.blade.php b/resources/views/components/blog/article.blade.php new file mode 100644 index 0000000..575b7d5 --- /dev/null +++ b/resources/views/components/blog/article.blade.php @@ -0,0 +1,26 @@ + + +
+ Back to articles + + +

{{ $article['title'] }}

+

{{ $article['summary'] }}

+ +
+ @foreach ($article['body'] as $paragraph) +

{{ $paragraph }}

+ @endforeach +
+ + +
diff --git a/resources/views/components/blog/hero.blade.php b/resources/views/components/blog/hero.blade.php new file mode 100644 index 0000000..a39dcf3 --- /dev/null +++ b/resources/views/components/blog/hero.blade.php @@ -0,0 +1,14 @@ + + +
+
Field Notes
+

Practical writing about better web systems.

+

Short articles on turning scattered pages into reusable layouts, focused snippets, and clearer content workflows.

+
diff --git a/resources/views/components/blog/post-card.blade.php b/resources/views/components/blog/post-card.blade.php new file mode 100644 index 0000000..e4af450 --- /dev/null +++ b/resources/views/components/blog/post-card.blade.php @@ -0,0 +1,15 @@ + + + +
{{ $post['date'] }} · {{ $post['read_time'] }}
+

{{ $post['title'] }}

+

{{ $post['summary'] }}

+ Read article +
diff --git a/resources/views/components/blog/post-grid.blade.php b/resources/views/components/blog/post-grid.blade.php new file mode 100644 index 0000000..ff0520f --- /dev/null +++ b/resources/views/components/blog/post-grid.blade.php @@ -0,0 +1,14 @@ + + +
+ @foreach ($posts as $post) + + @endforeach +
diff --git a/resources/views/layouts/blog.blade.php b/resources/views/layouts/blog.blade.php new file mode 100644 index 0000000..1a52496 --- /dev/null +++ b/resources/views/layouts/blog.blade.php @@ -0,0 +1,25 @@ + + + + @include('partials.head') + @vite(['resources/css/blog.css']) + {{ $title ?? 'Field Notes' }} + + +
+
+ Field Notes + +
+
+ +
+ {{ $slot }} +
+ + @fluxScripts + + diff --git a/resources/views/pages/blog.blade.php b/resources/views/pages/blog.blade.php new file mode 100644 index 0000000..4102c26 --- /dev/null +++ b/resources/views/pages/blog.blade.php @@ -0,0 +1,46 @@ +posts = BlogPost::query() + ->published() + ->ordered() + ->get() + ->map(fn (BlogPost $post): array => [ + 'slug' => $post->slug, + 'title' => $post->title, + 'date' => $post->created_at?->format('M j, Y') ?? 'Field note', + 'read_time' => $this->readTime($post->summary), + 'summary' => Str::limit($this->firstParagraph($post->summary), 150), + ]) + ->all(); + } + + private function firstParagraph(string $body): string + { + $paragraphs = preg_split('/\R{2,}/', trim($body)); + + return $paragraphs[0] ?? $body; + } + + private function readTime(string $body): string + { + $minutes = max(1, (int) ceil(str_word_count(strip_tags($body)) / 200)); + + return $minutes.' min read'; + } +}; +?> + +
+ + +
diff --git a/resources/views/pages/blog/show.blade.php b/resources/views/pages/blog/show.blade.php new file mode 100644 index 0000000..7f634af --- /dev/null +++ b/resources/views/pages/blog/show.blade.php @@ -0,0 +1,45 @@ +is_published, 404); + + $paragraphs = $this->paragraphs($post->summary); + $callout = count($paragraphs) > 2 ? array_pop($paragraphs) : $post->summary; + + $this->article = [ + 'title' => $post->title, + 'date' => $post->created_at?->format('M j, Y') ?? 'Field note', + 'read_time' => $this->readTime($post->summary), + 'summary' => Str::limit($paragraphs[0] ?? $post->summary, 180), + 'body' => $paragraphs, + 'callout' => $callout, + ]; + } + + private function paragraphs(string $body): array + { + return array_values(array_filter( + preg_split('/\R{2,}/', trim($body)) ?: [], + fn (string $paragraph): bool => trim($paragraph) !== '', + )); + } + + private function readTime(string $body): string + { + $minutes = max(1, (int) ceil(str_word_count(strip_tags($body)) / 200)); + + return $minutes.' min read'; + } +}; +?> + + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 13545d0..b298acb 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,202 +1,8 @@ - - - - - - - {{ __('Welcome') }} - {{ config('app.name', 'Laravel') }} - - - - - - @fonts - - - - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif - - + +
+
Welcome
+

The blog is now the home page.

+

The original starter welcome route has moved here so the root URL can serve Field Notes.

+

Go to Field Notes

+
+
diff --git a/tests/Feature/DynamicContentTest.php b/tests/Feature/DynamicContentTest.php index a6be130..4df9238 100644 --- a/tests/Feature/DynamicContentTest.php +++ b/tests/Feature/DynamicContentTest.php @@ -25,6 +25,8 @@ protected function setUp(): void { parent::setUp(); + $this->withoutVite(); + $this->originalBasePath = app()->basePath(); $this->testBasePath = storage_path('framework/testing/evolve-content-'.Str::random(8)); diff --git a/tests/Feature/EvolveLintCommandTest.php b/tests/Feature/EvolveLintCommandTest.php index 983e10e..6e16f5a 100644 --- a/tests/Feature/EvolveLintCommandTest.php +++ b/tests/Feature/EvolveLintCommandTest.php @@ -137,6 +137,29 @@ public function test_collisions_with_static_route_names_are_reported(): void $this->assertContains('shadows-static-route', $codes); } + public function test_root_artifact_route_does_not_collide_with_its_registered_route(): void + { + Route::get('/', fn () => null)->name('blog.index'); + + (new EvolveLibrary)->write([ + 'pages' => [ + [ + 'id' => 'blog', + 'name' => 'Blog', + 'path' => 'resources/views/pages/blog.blade.php', + 'route' => '/', + 'route_name' => 'blog.index', + 'php' => $this->componentPhp(), + 'blade' => '
Blog
', + ], + ], + ]); + + $payload = $this->runLintJson(); + + $this->assertSame([], $payload['findings']); + } + public function test_exit_code_is_nonzero_when_findings_exist(): void { (new EvolveLibrary)->write([ diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 70d12f0..cc849d7 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use Database\Seeders\BlogPostSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -9,10 +10,41 @@ class ExampleTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + $this->withoutVite(); + } + public function test_returns_a_successful_response(): void { $response = $this->get(route('home')); $response->assertOk(); } + + public function test_home_route_renders_blog_posts_from_content_model(): void + { + $this->seed(BlogPostSeeder::class); + + $this->assertDatabaseHas('blog_posts', [ + 'title' => 'The power of MCP tools', + 'slug' => 'the-power-of-mcp-tools', + ]); + + $this->get(route('home')) + ->assertOk() + ->assertSee('The power of MCP tools'); + } + + public function test_blog_article_route_renders_seeded_content_model_post(): void + { + $this->seed(BlogPostSeeder::class); + + $this->get('/blog/the-power-of-mcp-tools') + ->assertOk() + ->assertSee('The power of MCP tools') + ->assertSee('The server becomes a pure state holder and workflow system'); + } } diff --git a/vite.config.js b/vite.config.js index b401daa..d0ecea0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ export default defineConfig({ laravel({ input: [ 'resources/css/app.css', + 'resources/css/blog.css', 'resources/js/app.js', 'resources/js/passkeys.js', ],