Skip to content

feat(seo): add per-page title, canonical, sitemap and RSS feed - #10

Open
Guajir0-code wants to merge 3 commits into
sourcevortex:mainfrom
Guajir0-code:feat/seo-metadata
Open

feat(seo): add per-page title, canonical, sitemap and RSS feed#10
Guajir0-code wants to merge 3 commits into
sourcevortex:mainfrom
Guajir0-code:feat/seo-metadata

Conversation

@Guajir0-code

Copy link
Copy Markdown

Problema

Todas as páginas do site servem o mesmo <title>. Não há canonical, não há dados estruturados, não há sitemap, não há feed RSS, e a paginação não é rastreável.

Evidência

Verificado em https://beta.sourcevortex.com.br/ no dia da abertura deste PR:

$ curl -s https://beta.sourcevortex.com.br/ | grep -o '<title>.*</title>'
<title>SOURCEVORTEX - Um site sobre tecnologia, entretenimento, games, Linux e muito mais!</title>

$ curl -s https://beta.sourcevortex.com.br/2026/07/filme-diarios-de-uma-apotecaria... | grep -o '<title>.*</title>'
<title>SOURCEVORTEX - Um site sobre tecnologia, entretenimento, games, Linux e muito mais!</title>

Idênticos. Mas o og:title da mesma página de post está correto:

<meta property="og:title" content="Filme de Diários de uma Apotecária estreia em dezembro de 2026 - SOURCEVORTEX...">

Ou seja: o título certo já é calculado, só não estava sendo usado na tag <title>.

Também confirmado na mesma verificação: nenhum rel="canonical" e nenhum application/ld+json em nenhuma página.

Causa

app.blade.php emitia um título fixo:

<title inertia>{{ config('app.name', 'Laravel') }}</title>

O atributo inertia existe para que o Inertia substitua a tag no cliente — mas nenhuma página Vue usa <Head><title>. O único <Head> do projeto, em MainTemplate.vue, só injeta um <link> de fonte.

Enquanto isso, seo.blade.php já computava $ogTitle corretamente para os sete componentes.

Solução

Título: mover a tag para onde o valor já existe

{{-- seo.blade.php --}}
<title inertia>{{ $ogTitle }}</title>

Duas linhas. E, por rodar no Blade e não no Vue, funciona com ou sem SSR — o que importa, já que o processo de SSR não é garantido neste projeto.

Canonical

<link rel="canonical" href="{{ $canonical }}">

Sem query string, para que as variantes ?page= e ?s= não concorram com a página em si.

noindex em resultados de busca

Páginas de busca não têm o que fazer num índice, e o site aceita qualquer ?s=. Elas passam a emitir noindex, follow.

Dados estruturados

JSON-LD do tipo Article nas páginas de post: headline, imagem, data de publicação, autor com link e publisher. É o que permite ao buscador exibir o resultado como rich result em vez de link simples. Não é emitido em listagens.

og:description com fallback

post_excerpt costuma vir vazio no WordPress, e o site herdou esse dado. Quando vazio, a descrição passa a ser extraída do corpo do post, limitada a 160 caracteres.

/sitemap.xml e /feed

Novo FeedController, com as views correspondentes. O sitemap cobre posts, categorias, tags e a página de política; o feed traz os 20 posts mais recentes.

O feed fica em /feed, o mesmo endereço que o WordPress servia — pode haver assinantes ativos herdados do site anterior.

Ambos são cacheados (6h e 1h) e declarados antes das rotas de arquivo, para não serem capturados por elas.

robots.txt passa a apontar o sitemap.

Paginação rastreável

PostCardListPagination.vue navegava por router.get() em resposta a clique, sem nenhum <a href>. Nenhum rastreador alcançava a página 2.

const pageUrl = (page: number) => {
    const url = new URL(window.location.href);
    url.searchParams.set('page', String(page));
    return url.pathname + url.search;
};

Os itens passam a renderizar href real, mantendo a navegação via Inertia no clique.

Isso corrige de quebra um bug de comportamento: a navegação anterior usava window.location.pathname e montava a query do zero, descartando o ?s=. Numa busca, clicar na página 2 levava para /?page=2 e devolvia a listagem normal da home como se fossem resultados. No servidor, ->withQueryString() completa a correção.

Como validar

php vendor/bin/pest --filter=SeoTest
Tests: 10 passed (34 assertions)

A cobertura inclui: títulos distintos entre home, post e categoria; exatamente uma tag <title> por página; canonical sem query string; noindex apenas em busca; JSON-LD com os campos corretos e ausente em listagens; sitemap contendo post, categoria e tag; feed no endereço certo com o content-type certo; XML bem formado nos dois, validado com simplexml_load_string; e o fallback de descrição.

Verificado nos dois sentidos: contra o código anterior, 4 falham — título, canonical, noindex e JSON-LD.

Impacto

  • Comportamento: nenhuma mudança visual. Tudo aqui é <head>, XML novo e os href da paginação.
  • Rotas novas: /sitemap.xml e /feed.
  • Schema: nenhuma alteração de banco.
  • Compatibilidade: nenhuma variável de ambiente nova. O sitemap usa url(), então respeita APP_URL.
  • Rollback: reverter o commit.

Uma verificação que precisa ser feita com dados reais

O robots.txt referencia https://sourcevortex.com.br/sitemap.xml. Se o V2 for ao ar em outro domínio, esse endereço precisa acompanhar.

Vale também conferir uma amostra de URLs já indexadas antes do corte de V1 para V2. As rotas atuais (/{year}/{month}/{slug}, /category/, /tag/, /author/) coincidem com o permalink padrão do WordPress, e o .htaccess já faz 301 removendo a barra final — mas isso merece confirmação empírica, porque é o único risco irreversível desta série.

Fora de escopo

  • rel="prev" / rel="next" nas páginas de listagem.
  • Sitemap paginado. O atual tem teto de 2000 posts numa única resposta; acima disso o formato pede índice de sitemaps.
  • Imagem de OG por página de listagem. Categorias, tags e arquivos continuam usando a imagem padrão.
  • SSR. Esta implementação foi feita de propósito para não depender dele.

Guajir0-code and others added 3 commits August 5, 2026 10:10
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Every page of the site served the same <title>: 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant