From a2f46b9f7d27f5972fbb60aca2f409bb86d4692d Mon Sep 17 00:00:00 2001 From: Guajiro <276488307+Guajir0-code@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:10:55 -0300 Subject: [PATCH 1/3] test: add WordPress fixtures and route coverage The app points Eloquent at an existing WordPress database, so no migration creates wp_posts, wp_users, wp_terms and friends. Any test that touched the database therefore could not run, and the single feature test in the repo (GET / asserting 200) failed on a missing database. Add a schema builder for the wp_* tables the app reads, small row builders, and smoke coverage for all seven routes in routes/web.php, including the highlighted-post exclusion, search, and the published/scheduled filtering the global scope is responsible for. withoutVite() keeps the suite independent of `npm run build`. ExampleTest is dropped: RoutesTest covers GET / properly. Co-Authored-By: Claude Opus 5 --- phpunit.xml | 3 +- tests/Feature/ExampleTest.php | 7 -- tests/Feature/RoutesTest.php | 139 ++++++++++++++++++++++++++++++ tests/Pest.php | 11 +++ tests/Support/WordPressSchema.php | 104 ++++++++++++++++++++++ tests/Support/Wp.php | 130 ++++++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 8 deletions(-) delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/RoutesTest.php create mode 100644 tests/Support/WordPressSchema.php create mode 100644 tests/Support/Wp.php diff --git a/phpunit.xml b/phpunit.xml index c09b5bc..61c031c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,7 +22,8 @@ - + + diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 1e2a000..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/RoutesTest.php b/tests/Feature/RoutesTest.php new file mode 100644 index 0000000..8ebf750 --- /dev/null +++ b/tests/Feature/RoutesTest.php @@ -0,0 +1,139 @@ +author = Wp::user([ + 'user_login' => 'mayron', + 'user_nicename' => 'mayron', + 'display_name' => 'Autor Um', + 'user_email' => 'autor@example.com', + ]); + + $this->post = Wp::post([ + 'post_author' => $this->author->ID, + 'post_title' => 'Primeiro post', + 'post_name' => 'primeiro-post', + 'post_date' => '2026-07-15 12:00:00', + ]); + + Wp::categorise($this->post->ID, 'games'); + Wp::categorise($this->post->ID, 'lancamentos', 'post_tag'); + Wp::thumbnail($this->post->ID); +}); + +it('renders the home page', function () { + $this->get('/') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Home') + ->has('posts.data', 1) + ->where('posts.data.0.title', 'Primeiro post') + ); +}); + +it('excludes highlighted posts from the home listing', function () { + $highlighted = Wp::post(['post_title' => 'Em destaque', 'post_name' => 'em-destaque']); + Wp::categorise($highlighted->ID, 'destaques'); + + $this->get('/') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('posts.data', 1) + ->has('highlightedPosts.data', 1) + ->where('posts.data.0.title', 'Primeiro post') + ->where('highlightedPosts.data.0.title', 'Em destaque') + ); +}); + +it('renders a single post', function () { + $this->get('/2026/07/primeiro-post') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('PostContent') + ->where('post.data.title', 'Primeiro post') + ->where('post.data.slug', 'primeiro-post') + ); +}); + +it('returns 404 for an unknown post slug', function () { + $this->get('/2026/07/nao-existe')->assertNotFound(); +}); + +it('renders the category listing', function () { + $this->get('/category/games') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('CategoryPosts') + ->has('posts.data', 1) + ); +}); + +it('renders the tag listing', function () { + $this->get('/tag/lancamentos') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('TagPosts') + ->has('posts.data', 1) + ); +}); + +it('renders the author listing', function () { + $this->get('/author/mayron') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('AuthorPosts') + ->has('posts.data', 1) + ); +}); + +it('renders the year and month archive', function () { + $this->get('/2026/07') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('YearMonthPosts') + ->has('posts.data', 1) + ); +}); + +it('renders the privacy policy page', function () { + Wp::post([ + 'post_title' => 'Politica de Privacidade', + 'post_name' => 'politica-de-privacidade', + 'post_type' => 'page', + ]); + + $this->get('/politica-de-privacidade') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('PrivacyPolicy') + ->where('page.data.title', 'Politica de Privacidade') + ); +}); + +it('finds posts by search term', function () { + Wp::post(['post_title' => 'Outro assunto', 'post_name' => 'outro-assunto']); + + $this->get('/?s=Primeiro') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('searchTerm', 'Primeiro') + ->has('posts.data', 1) + ->where('posts.data.0.title', 'Primeiro post') + ); +}); + +it('hides posts that are not published', function () { + Wp::post(['post_title' => 'Rascunho', 'post_name' => 'rascunho', 'post_status' => 'draft']); + Wp::post(['post_title' => 'Futuro', 'post_name' => 'futuro', 'post_date' => '2099-01-01 00:00:00']); + + $this->get('/') + ->assertOk() + ->assertInertia(fn ($page) => $page->has('posts.data', 1)); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 40d096b..3c76ee0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,6 +13,17 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->beforeEach(function () { + // The app reads an existing WordPress database, so there are no + // migrations for wp_* tables. Build them per test instead. + Tests\Support\WordPressSchema::create(); + + // Rendering the root Blade view resolves @vite, which needs a built + // manifest. Stub it so the suite does not depend on `npm run build`. + $this->withoutVite(); + + cache()->flush(); + }) ->in('Feature'); /* diff --git a/tests/Support/WordPressSchema.php b/tests/Support/WordPressSchema.php new file mode 100644 index 0000000..df23af6 --- /dev/null +++ b/tests/Support/WordPressSchema.php @@ -0,0 +1,104 @@ + Drop order matters only for readability; sqlite has no FKs here. + */ + private const TABLES = [ + 'wp_posts', + 'wp_postmeta', + 'wp_users', + 'wp_usermeta', + 'wp_terms', + 'wp_term_taxonomy', + 'wp_term_relationships', + ]; + + public static function create(): void + { + self::drop(); + + Schema::create('wp_posts', function (Blueprint $table) { + $table->id('ID'); + $table->unsignedBigInteger('post_author')->default(0); + $table->dateTime('post_date')->nullable(); + $table->longText('post_content')->nullable(); + $table->text('post_title')->nullable(); + $table->text('post_excerpt')->nullable(); + $table->string('post_status', 20)->default('publish'); + $table->string('post_name', 200)->default(''); + $table->string('post_type', 20)->default('post'); + $table->string('guid', 255)->default(''); + $table->integer('menu_order')->default(0); + $table->unsignedBigInteger('post_parent')->default(0); + }); + + Schema::create('wp_postmeta', function (Blueprint $table) { + $table->id('meta_id'); + $table->unsignedBigInteger('post_id')->default(0); + $table->string('meta_key', 255)->nullable(); + $table->longText('meta_value')->nullable(); + }); + + Schema::create('wp_users', function (Blueprint $table) { + $table->id('ID'); + $table->string('user_login', 60)->default(''); + $table->string('user_pass', 255)->default(''); + $table->string('user_nicename', 50)->default(''); + $table->string('user_email', 100)->default(''); + $table->string('display_name', 250)->default(''); + }); + + Schema::create('wp_usermeta', function (Blueprint $table) { + $table->id('umeta_id'); + $table->unsignedBigInteger('user_id')->default(0); + $table->string('meta_key', 255)->nullable(); + $table->longText('meta_value')->nullable(); + }); + + Schema::create('wp_terms', function (Blueprint $table) { + $table->id('term_id'); + $table->string('name', 200)->default(''); + $table->string('slug', 200)->default(''); + }); + + Schema::create('wp_term_taxonomy', function (Blueprint $table) { + $table->id('term_taxonomy_id'); + $table->unsignedBigInteger('term_id')->default(0); + $table->string('taxonomy', 32)->default(''); + $table->longText('description')->nullable(); + $table->unsignedBigInteger('parent')->default(0); + $table->bigInteger('count')->default(0); + }); + + Schema::create('wp_term_relationships', function (Blueprint $table) { + $table->unsignedBigInteger('object_id')->default(0); + $table->unsignedBigInteger('term_taxonomy_id')->default(0); + $table->integer('term_order')->default(0); + }); + } + + public static function drop(): void + { + foreach (self::TABLES as $table) { + Schema::dropIfExists($table); + } + } +} diff --git a/tests/Support/Wp.php b/tests/Support/Wp.php new file mode 100644 index 0000000..9c29ebf --- /dev/null +++ b/tests/Support/Wp.php @@ -0,0 +1,130 @@ +insertGetId(array_merge([ + 'user_login' => 'autor', + 'user_pass' => '$P$Bexamplehashexamplehashexample', + 'user_nicename' => 'autor', + 'user_email' => 'autor@example.com', + 'display_name' => 'Autor de Teste', + ], $attributes), 'ID'); + + return WpUser::query()->findOrFail($id); + } + + public static function post(array $attributes = []): WpPost + { + $id = DB::table('wp_posts')->insertGetId(array_merge([ + 'post_author' => 1, + 'post_date' => '2026-07-15 12:00:00', + 'post_title' => 'Post de teste', + 'post_excerpt' => 'Resumo do post.', + 'post_content' => 'Conteudo do post.', + 'post_name' => 'post-de-teste', + 'post_status' => 'publish', + 'post_type' => 'post', + ], $attributes), 'ID'); + + return WpPost::withoutGlobalScopes()->findOrFail($id); + } + + /** + * Creates a term in the given taxonomy and returns its term_taxonomy_id. + */ + public static function term(string $slug, string $taxonomy = 'category', ?string $name = null): int + { + $termId = DB::table('wp_terms')->insertGetId([ + 'name' => $name ?? ucfirst($slug), + 'slug' => $slug, + ], 'term_id'); + + return DB::table('wp_term_taxonomy')->insertGetId([ + 'term_id' => $termId, + 'taxonomy' => $taxonomy, + ], 'term_taxonomy_id'); + } + + public static function attach(int $postId, int $termTaxonomyId): void + { + DB::table('wp_term_relationships')->insert([ + 'object_id' => $postId, + 'term_taxonomy_id' => $termTaxonomyId, + ]); + } + + /** + * Convenience: create the term if needed and attach it in one call. + */ + public static function categorise(int $postId, string $slug, string $taxonomy = 'category'): int + { + $ttId = DB::table('wp_term_taxonomy') + ->join('wp_terms', 'wp_terms.term_id', '=', 'wp_term_taxonomy.term_id') + ->where('wp_terms.slug', $slug) + ->where('wp_term_taxonomy.taxonomy', $taxonomy) + ->value('wp_term_taxonomy.term_taxonomy_id'); + + $ttId ??= self::term($slug, $taxonomy); + + self::attach($postId, $ttId); + + return $ttId; + } + + /** + * Attaches a featured image: an attachment post plus the _thumbnail_id meta + * that WpPost::getThumbnail() resolves. + */ + public static function thumbnail(int $postId, string $url = 'https://example.com/capa.jpg'): int + { + $attachmentId = DB::table('wp_posts')->insertGetId([ + 'post_title' => 'capa', + 'post_name' => 'capa', + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'post_date' => '2026-07-15 12:00:00', + 'guid' => $url, + ], 'ID'); + + DB::table('wp_postmeta')->insert([ + 'post_id' => $postId, + 'meta_key' => '_thumbnail_id', + 'meta_value' => (string) $attachmentId, + ]); + + return $attachmentId; + } + + public static function postMeta(int $postId, string $key, string $value): void + { + DB::table('wp_postmeta')->insert([ + 'post_id' => $postId, + 'meta_key' => $key, + 'meta_value' => $value, + ]); + } + + public static function userMeta(int $userId, string $key, string $value): void + { + DB::table('wp_usermeta')->insert([ + 'user_id' => $userId, + 'meta_key' => $key, + 'meta_value' => $value, + ]); + } +} From 8c18d528a4424c32fe3cfe61f4936d585ace02ce Mon Sep 17 00:00:00 2001 From: Guajiro <276488307+Guajir0-code@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:17:27 -0300 Subject: [PATCH 2/3] perf(posts): eliminate N+1 on terms, author and thumbnails Three separate per-record queries were issued while rendering any listing: * terms was never eager loaded anywhere, yet WpPostResource::getCategories() reads it for every post * getThumbnail() called $this->metadata() (the relation method, bypassing the eager loaded collection) and then looked up the attachment guid, costing two queries per post * author was missing from the home, highlights and related-posts queries WpPostResource also chose its shape from $request->routeIs('post'), so the related posts rendered on a post page received the full detail treatment, including running EmbedProcessorService over content the "Veja tambem" cards never display. Replaced with an explicit ->detailed() opt-in from the controller. Featured images are now a HasOneThrough relation via WpAttachment, a model on wp_posts without WpPostScope, so they can be eager loaded in one query. preventLazyLoading is enabled outside production so this cannot silently regress. Measured with 10 posts: home 45 -> 7 queries category 36 -> 6 queries post 21 -> 11 queries Co-Authored-By: Claude Opus 5 --- app/Http/Controllers/PostController.php | 2 +- app/Http/Resources/WpPostResource.php | 24 +++++++- app/Models/WpAttachment.php | 21 +++++++ app/Models/WpPost.php | 31 +++++++--- app/Providers/AppServiceProvider.php | 5 +- app/Services/WpPostService.php | 17 +++--- tests/Feature/QueryCountTest.php | 80 +++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 app/Models/WpAttachment.php create mode 100644 tests/Feature/QueryCountTest.php diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 022665d..985fa69 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -93,7 +93,7 @@ public function show($year, $month, $slug) $relatedPosts = WpPostService::getRelatedPosts($post); return inertia('PostContent', [ - 'post' => WpPostResource::make($post), + 'post' => WpPostResource::make($post)->detailed(), 'relatedPosts' => WpPostResource::collection($relatedPosts), ]); } diff --git a/app/Http/Resources/WpPostResource.php b/app/Http/Resources/WpPostResource.php index 88891d4..22dd4e0 100644 --- a/app/Http/Resources/WpPostResource.php +++ b/app/Http/Resources/WpPostResource.php @@ -26,6 +26,28 @@ */ class WpPostResource extends JsonResource { + /** + * Whether to include the heavy, single-post-only fields. + */ + private bool $detailed = false; + + /** + * Opt in to the full payload: processed content, subtitle, tags and author + * biography. + * + * This used to be inferred from $request->routeIs('post'), which meant the + * related posts rendered on that same page were also given the full + * treatment — including running EmbedProcessorService over their content — + * even though the component that renders them only reads the title, + * thumbnail, slug and date. + */ + public function detailed(): static + { + $this->detailed = true; + + return $this; + } + /** * Transform the resource into an array. * @@ -50,7 +72,7 @@ public function toArray(Request $request): array ], ]; - if ($request->routeIs('post')) { + if ($this->detailed) { $postData['content'] = EmbedProcessorService::processContent($this->post_content); $postData['subtitle'] = $this->metadata->filter(fn ($meta) => $meta->meta_key === 'subtitle')?->first()?->meta_value; $postData['date']['day'] = $parsedPostDate->format('d'); diff --git a/app/Models/WpAttachment.php b/app/Models/WpAttachment.php new file mode 100644 index 0000000..45918e5 --- /dev/null +++ b/app/Models/WpAttachment.php @@ -0,0 +1,21 @@ + wp_postmeta + * (_thumbnail_id) -> wp_posts (the attachment). Reading it through + * $this->metadata() instead would issue two queries for every post. + */ + public function thumbnail(): HasOneThrough { - if (! $postMeta = $this->metadata()->thumbnailId()->first()) { - return ''; - } + return $this->hasOneThrough( + WpAttachment::class, + WpPostMeta::class, + 'post_id', // wp_postmeta.post_id -> wp_posts.ID (this model) + 'ID', // wp_posts.ID (attachment) -> wp_postmeta.meta_value + 'ID', + 'meta_value', + ) + ->where('wp_postmeta.meta_key', '_thumbnail_id') + ->where('wp_posts.post_type', 'attachment'); + } - return DB::table('wp_posts') - ->where('ID', $postMeta->meta_value) - ->where('post_type', 'attachment') - ->value('guid') ?? ''; + public function getThumbnail(): string + { + return $this->thumbnail?->guid ?? ''; } #[Scope] diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..fcf4c23 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +20,8 @@ public function register(): void */ public function boot(): void { - // + // Turns an accidental lazy load into a loud failure while developing + // and in CI, instead of a silent extra query per record in production. + Model::preventLazyLoading(! $this->app->isProduction()); } } diff --git a/app/Services/WpPostService.php b/app/Services/WpPostService.php index 6e463f1..bedc82b 100644 --- a/app/Services/WpPostService.php +++ b/app/Services/WpPostService.php @@ -19,7 +19,7 @@ public static function getHighlightedPosts(): Collection return cache()->remember($cacheKey, $cacheTTL, function () { return WpPost::query() - ->with(['metadata']) + ->with(['terms', 'author', 'thumbnail']) ->whereCategorySlug('destaques') ->limit(self::DEFAULT_LIMIT_HIGHLIGHT_POSTS) ->get(); @@ -34,7 +34,7 @@ public static function getHomePosts(int $page, ?string $searchTerm = null): Leng return cache()->remember($cacheKey, $cacheTTL, function () use ($searchTerm) { return WpPost::query() - ->with(['metadata']) + ->with(['terms', 'author', 'thumbnail']) ->whereNotCategorySlug('destaques') ->useSearchTerm($searchTerm) ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); @@ -48,7 +48,7 @@ public static function getPostsByCategorySlug(int $page, string $slug): LengthAw return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) { return WpPost::query() - ->with(['metadata', 'author']) + ->with(['terms', 'author', 'thumbnail']) ->whereCategorySlug($slug) ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); }); @@ -61,7 +61,7 @@ public static function getPostsByAuthorNicename(int $page, string $nicename): Le return cache()->remember($cacheKey, $cacheTTL, function () use ($nicename) { return WpPost::query() - ->with(['metadata', 'author']) + ->with(['terms', 'author', 'thumbnail']) ->whereAuthorNicename($nicename) ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); }); @@ -74,7 +74,7 @@ public static function getPostsByYearMonth(int $page, string $year, string $mont return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month) { return WpPost::query() - ->with(['metadata', 'author']) + ->with(['terms', 'author', 'thumbnail']) ->whereYear('post_date', $year) ->whereMonth('post_date', $month) ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); @@ -88,7 +88,7 @@ public static function getPostsByTagSlug(int $page, string $slug): LengthAwarePa return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) { return WpPost::query() - ->with(['metadata', 'author']) + ->with(['terms', 'author', 'thumbnail']) ->whereTagSlug($slug) ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); }); @@ -101,6 +101,9 @@ public static function getPostByYearMonthSlug($year, $month, $slug): WpPost return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month, $slug) { return WpPost::query() + // The single post view also renders the subtitle meta and the + // author biography, so it needs more than the listings do. + ->with(['terms', 'thumbnail', 'metadata', 'author.metadata']) ->whereYear('post_date', $year) ->whereMonth('post_date', $month) ->where('post_name', $slug) @@ -124,7 +127,7 @@ public static function getRelatedPosts(WpPost $post, int $limit = 3): Collection } return WpPost::query() - ->with(['metadata']) + ->with(['terms', 'author', 'thumbnail']) ->whereCategorySlug($categorySlug) ->where('ID', '!=', $post->ID) ->limit($limit) diff --git a/tests/Feature/QueryCountTest.php b/tests/Feature/QueryCountTest.php new file mode 100644 index 0000000..acaa2a4 --- /dev/null +++ b/tests/Feature/QueryCountTest.php @@ -0,0 +1,80 @@ + 'autor', 'user_nicename' => 'autor']); + + for ($i = 1; $i <= $count; $i++) { + $post = Wp::post([ + 'post_author' => $author->ID, + 'post_title' => "Post {$i}", + 'post_name' => "post-{$i}", + 'post_date' => '2026-07-'.str_pad((string) $i, 2, '0', STR_PAD_LEFT).' 12:00:00', + ]); + + Wp::categorise($post->ID, 'games'); + Wp::thumbnail($post->ID); + } +} + +it('does not scale queries with the number of posts on the home page', function () { + seedPosts(3); + $few = countQueries(fn () => $this->get('/')->assertOk()); + + // Fresh state, ten times the posts. + Tests\Support\WordPressSchema::create(); + cache()->flush(); + seedPosts(30); + $many = countQueries(fn () => $this->get('/')->assertOk()); + + // The point of the test: the count must not track the post count. + expect($many)->toBe($few); +}); + +it('keeps the home page within a query budget', function () { + seedPosts(10); + + // measured: 7 + expect(countQueries(fn () => $this->get('/')->assertOk())) + ->toBeLessThanOrEqual(9); +}); + +it('keeps the single post page within a query budget', function () { + seedPosts(10); + + // measured: 11 — higher than the listings because it also loads the + // subtitle meta, the author biography and the related posts. + expect(countQueries(fn () => $this->get('/2026/07/post-1')->assertOk())) + ->toBeLessThanOrEqual(13); +}); + +it('keeps the category listing within a query budget', function () { + seedPosts(10); + + // measured: 6 + expect(countQueries(fn () => $this->get('/category/games')->assertOk())) + ->toBeLessThanOrEqual(8); +}); From 8222af15d1aec7a837fa4d42a5dfddaba53c6077 Mon Sep 17 00:00:00 2001 From: Guajiro <276488307+Guajir0-code@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:32:53 -0300 Subject: [PATCH 3/3] feat(seo): add per-page title, canonical, sitemap and RSS feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page of the site served the same : app.blade.php emitted config('app.name') and no page component ever set one. The og:title was already correct, because seo.blade.php computes a proper title for all seven components — it just was not being used for the title tag. Moving <title> into that partial fixes it in two lines and, unlike setting it from the Vue pages, works whether or not the SSR process is running. Also in this change: * canonical url, without the query string, so ?page= and ?s= variants do not compete with the page itself * noindex on search result pages * Article JSON-LD on post pages: headline, image, publish date, author and publisher * og:description falls back to the body when post_excerpt is empty, which is the common case in WordPress * /sitemap.xml covering posts, categories, tags and the privacy page * /feed, at the address WordPress served, so existing subscribers keep working * robots.txt points at the sitemap * pagination renders real hrefs, so crawlers can reach page 2 and beyond The pagination links also carry the current query string. Navigating with only { page } dropped ?s=, which silently turned page 2 of a search into the plain home listing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- app/Http/Controllers/FeedController.php | 61 ++++++++ app/Services/WpPostService.php | 15 +- public/robots.txt | 2 + .../js/components/PostCardListPagination.vue | 19 ++- resources/views/app.blade.php | 4 +- resources/views/feed.blade.php | 24 ++++ resources/views/seo.blade.php | 59 ++++++++ resources/views/sitemap.blade.php | 36 +++++ routes/web.php | 5 + tests/Feature/SeoTest.php | 132 ++++++++++++++++++ 10 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/FeedController.php create mode 100644 resources/views/feed.blade.php create mode 100644 resources/views/sitemap.blade.php create mode 100644 tests/Feature/SeoTest.php diff --git a/app/Http/Controllers/FeedController.php b/app/Http/Controllers/FeedController.php new file mode 100644 index 0000000..c4dce69 --- /dev/null +++ b/app/Http/Controllers/FeedController.php @@ -0,0 +1,61 @@ +<?php + +namespace App\Http\Controllers; + +use App\Models\WpPost; +use App\Models\WpTerm; +use Illuminate\Http\Response; + +class FeedController extends Controller +{ + private const SITEMAP_POST_LIMIT = 2000; + + private const FEED_POST_LIMIT = 20; + + public function sitemap(): Response + { + $xml = cache()->remember('sitemap_xml', now()->addHours(6), function () { + $posts = WpPost::query() + ->select(['ID', 'post_name', 'post_date']) + ->limit(self::SITEMAP_POST_LIMIT) + ->get(); + + $categories = self::terms('category'); + $tags = self::terms('post_tag'); + + return view('sitemap', compact('posts', 'categories', 'tags'))->render(); + }); + + return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']); + } + + /** + * Kept at the address WordPress served, so existing subscribers keep working. + */ + public function feed(): Response + { + $xml = cache()->remember('feed_xml', now()->addHour(), function () { + $posts = WpPost::query() + ->with(['author']) + ->limit(self::FEED_POST_LIMIT) + ->get(); + + return view('feed', compact('posts'))->render(); + }); + + return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + } + + /** + * @return \Illuminate\Support\Collection<int, \stdClass> + */ + private static function terms(string $taxonomy) + { + return WpTerm::query() + ->select(['wp_terms.slug']) + ->join('wp_term_taxonomy', 'wp_term_taxonomy.term_id', '=', 'wp_terms.term_id') + ->where('wp_term_taxonomy.taxonomy', $taxonomy) + ->where('wp_term_taxonomy.count', '>', 0) + ->get(); + } +} diff --git a/app/Services/WpPostService.php b/app/Services/WpPostService.php index bedc82b..cb8569e 100644 --- a/app/Services/WpPostService.php +++ b/app/Services/WpPostService.php @@ -37,7 +37,8 @@ public static function getHomePosts(int $page, ?string $searchTerm = null): Leng ->with(['terms', 'author', 'thumbnail']) ->whereNotCategorySlug('destaques') ->useSearchTerm($searchTerm) - ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); + ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS) + ->withQueryString(); }); } @@ -50,7 +51,8 @@ public static function getPostsByCategorySlug(int $page, string $slug): LengthAw return WpPost::query() ->with(['terms', 'author', 'thumbnail']) ->whereCategorySlug($slug) - ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); + ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS) + ->withQueryString(); }); } @@ -63,7 +65,8 @@ public static function getPostsByAuthorNicename(int $page, string $nicename): Le return WpPost::query() ->with(['terms', 'author', 'thumbnail']) ->whereAuthorNicename($nicename) - ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); + ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS) + ->withQueryString(); }); } @@ -77,7 +80,8 @@ public static function getPostsByYearMonth(int $page, string $year, string $mont ->with(['terms', 'author', 'thumbnail']) ->whereYear('post_date', $year) ->whereMonth('post_date', $month) - ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); + ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS) + ->withQueryString(); }); } @@ -90,7 +94,8 @@ public static function getPostsByTagSlug(int $page, string $slug): LengthAwarePa return WpPost::query() ->with(['terms', 'author', 'thumbnail']) ->whereTagSlug($slug) - ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS); + ->paginate(self::DEFAULT_PER_PAGE_ON_LISTS) + ->withQueryString(); }); } diff --git a/public/robots.txt b/public/robots.txt index eb05362..ee61b75 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,4 @@ User-agent: * Disallow: + +Sitemap: https://sourcevortex.com.br/sitemap.xml diff --git a/resources/js/components/PostCardListPagination.vue b/resources/js/components/PostCardListPagination.vue index 7709c80..0dc67fd 100644 --- a/resources/js/components/PostCardListPagination.vue +++ b/resources/js/components/PostCardListPagination.vue @@ -11,8 +11,22 @@ const props = defineProps<{ const currentPage = ref(props.backendCurrentPage); +/** + * Real URL for a page number. + * + * Rendered into href so crawlers can follow pagination, and so the current + * query string survives — navigating with only { page } used to drop ?s= and + * silently turn page 2 of a search into the plain home listing. + */ +const pageUrl = (page: number) => { + const url = new URL(window.location.href); + url.searchParams.set('page', String(page)); + + return url.pathname + url.search; +}; + watch(currentPage, (newPage) => { - router.get(window.location.pathname, { page: newPage }, { preserveState: true, preserveScroll: true, replace: true }); + router.get(pageUrl(newPage), {}, { preserveState: true, preserveScroll: true, replace: true }); }); </script> @@ -32,8 +46,11 @@ watch(currentPage, (newPage) => { v-if="item.type === 'page'" :value="item.value" :is-active="item.value === page" + as="a" + :href="pageUrl(item.value)" class="cursor-pointer border-0 bg-transparent! text-2xl text-[#e6c619] shadow-none" :class="{ 'font-bold text-black dark:text-white': item.value === page }" + @click.prevent="currentPage = item.value" > {{ item.value }} </PaginationItem> diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 0eeeb92..0c656ec 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -19,7 +19,9 @@ })(); </script> - <title inertia>{{ config('app.name', 'Laravel') }} + {{-- The per-page lives in the seo partial, which already + computes it for every component. Keeping it there means it is + correct without depending on SSR being available. --}} <link rel="icon" href="/favicon.ico" sizes="any"> <link rel="icon" href="/favicon.webp" type="image/webp"> diff --git a/resources/views/feed.blade.php b/resources/views/feed.blade.php new file mode 100644 index 0000000..6e1e387 --- /dev/null +++ b/resources/views/feed.blade.php @@ -0,0 +1,24 @@ +<?php echo '<?xml version="1.0" encoding="UTF-8"?>'."\n"; ?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> + <channel> + <title>{{ config('app.name') }} + {{ url('/') }} + {{ config('app.name') }} + pt-BR + +@foreach($posts as $post) +@php($date = \Carbon\Carbon::parse($post->post_date)) +@php($link = url(sprintf('/%s/%s/%s', $date->format('Y'), $date->format('m'), $post->post_name))) + + {{ strip_tags($post->post_title) }} + {{ $link }} + {{ $link }} + {{ $date->toRfc2822String() }} +@if($post->author) + {{ $post->author->display_name }} +@endif + {{ \Illuminate\Support\Str::limit(trim(strip_tags($post->post_excerpt ?: $post->post_content)), 300) }} + +@endforeach + + diff --git a/resources/views/seo.blade.php b/resources/views/seo.blade.php index c26cd4f..c0d8ff3 100644 --- a/resources/views/seo.blade.php +++ b/resources/views/seo.blade.php @@ -10,6 +10,9 @@ $ogImage = asset('apple-touch-icon.png'); $ogType = 'website'; $ogUrl = url()->current(); + $canonical = url()->current(); + $isSearch = $component === 'Home' && !empty($props['searchTerm']); + $article = null; // PostContent - Página interna de post if ($component === 'PostContent' && isset($props['post']['data'])) { @@ -18,6 +21,16 @@ $ogDescription = strip_tags($post['excerpt'] ?? ''); $ogImage = $post['thumbnail'] ?? $ogImage; $ogType = 'article'; + + // post_excerpt is frequently empty in WordPress; fall back to the body. + if ($ogDescription === '') { + $ogDescription = \Illuminate\Support\Str::limit( + trim(preg_replace('/\s+/u', ' ', strip_tags($post['content'] ?? ''))), + 160, + ); + } + + $article = $post; } // CategoryPosts - Listagem por categoria @@ -64,6 +77,17 @@ } @endphp +{{ $ogTitle }} + +{{-- Canonical: the address this page should be indexed under, without the + query string, so ?page= and ?s= variants do not compete with it. --}} + + +@if($isSearch) + {{-- Search result pages have no business in an index. --}} + +@endif + {{-- Open Graph --}} @@ -107,3 +131,38 @@ @if($lcpImage) @endif + +{{-- Structured data: lets search engines show the headline, date and author + as a rich result instead of a plain link. --}} +@if($article) + +@endif diff --git a/resources/views/sitemap.blade.php b/resources/views/sitemap.blade.php new file mode 100644 index 0000000..1540766 --- /dev/null +++ b/resources/views/sitemap.blade.php @@ -0,0 +1,36 @@ +'."\n"; ?> + + + {{ url('/') }} + daily + 1.0 + +@foreach($posts as $post) +@php($date = \Carbon\Carbon::parse($post->post_date)) + + {{ url(sprintf('/%s/%s/%s', $date->format('Y'), $date->format('m'), $post->post_name)) }} + {{ $date->toAtomString() }} + monthly + 0.8 + +@endforeach +@foreach($categories as $category) + + {{ url('/category/'.$category->slug) }} + weekly + 0.6 + +@endforeach +@foreach($tags as $tag) + + {{ url('/tag/'.$tag->slug) }} + weekly + 0.4 + +@endforeach + + {{ route('page.privacy-policy') }} + yearly + 0.1 + + diff --git a/routes/web.php b/routes/web.php index bb65cc6..d0f3040 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,15 @@ name('home.posts'); Route::get('/politica-de-privacidade', [PostController::class, 'privacyPolicy'])->name('page.privacy-policy'); + +// Declared before the archive routes so they are never shadowed by them. +Route::get('/sitemap.xml', [FeedController::class, 'sitemap'])->name('sitemap'); +Route::get('/feed', [FeedController::class, 'feed'])->name('feed'); Route::get('/author/{author}', [PostController::class, 'authorPosts'])->name('author.posts'); Route::get('/category/{category}', [PostController::class, 'categoryPosts'])->name('category.posts'); Route::get('/tag/{slug}', [PostController::class, 'tagPosts'])->name('tag.posts'); diff --git a/tests/Feature/SeoTest.php b/tests/Feature/SeoTest.php new file mode 100644 index 0000000..13473cf --- /dev/null +++ b/tests/Feature/SeoTest.php @@ -0,0 +1,132 @@ +author = Wp::user([ + 'user_login' => 'mayron', + 'user_nicename' => 'mayron', + 'display_name' => 'Autor Um', + ]); + + $this->post = Wp::post([ + 'post_author' => $this->author->ID, + 'post_title' => 'Titulo do post', + 'post_excerpt' => 'Resumo do post.', + 'post_name' => 'titulo-do-post', + 'post_date' => '2026-07-15 12:30:00', + ]); + + Wp::categorise($this->post->ID, 'games'); + Wp::thumbnail($this->post->ID, 'https://example.com/capa.jpg'); +}); + +function titleOf(string $html): string +{ + preg_match('/]*>(.*?)<\/title>/s', $html, $m); + + return trim($m[1] ?? ''); +} + +it('gives each page its own title', function () { + $home = titleOf($this->get('/')->getContent()); + $post = titleOf($this->get('/2026/07/titulo-do-post')->getContent()); + $category = titleOf($this->get('/category/games')->getContent()); + + expect($post)->toContain('Titulo do post') + ->and($category)->toContain('Games') + // the regression this guards: every page used to share one title + ->and($post)->not->toBe($home) + ->and($category)->not->toBe($home) + ->and($post)->not->toBe($category); +}); + +it('emits exactly one title tag', function () { + $html = $this->get('/2026/07/titulo-do-post')->getContent(); + + expect(substr_count($html, 'toBe(1); +}); + +it('emits a canonical url without the query string', function () { + $html = $this->get('/2026/07/titulo-do-post?page=2&utm_source=x')->getContent(); + + expect($html)->toContain(''); +}); + +it('keeps search result pages out of the index', function () { + $withSearch = $this->get('/?s=titulo')->getContent(); + $withoutSearch = $this->get('/')->getContent(); + + expect($withSearch)->toContain('name="robots" content="noindex, follow"') + ->and($withoutSearch)->not->toContain('noindex'); +}); + +it('describes the post as structured data', function () { + $html = $this->get('/2026/07/titulo-do-post')->getContent(); + + expect($html)->toContain('application/ld+json'); + + preg_match('/