import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import type { Metadata } from "next"; import { siteUrl } from "@/lib/site-content"; import { metadata as layoutMetadata } from "./layout"; import { metadata as notFoundMetadata } from "./not-found"; import sitemap from "./sitemap"; // o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe jest.mock("next/font/google", () => ({ Geist_Mono: () => ({ variable: "font-mono-var" }), Space_Grotesk: () => ({ variable: "font-display-var" }), })); jest.mock("./globals.css", () => ({})); /** Sufixo que o layout acrescenta a todo título (" | Evandro.dev.br"): 17 caracteres, não 16; por isso o teto efetivo do título é 43. */ const TITLE_SUFFIX = " | Evandro.dev.br"; const MAX_META_TITLE = 44; const MAX_FULL_TITLE = 60; const MAX_DESCRIPTION = 155; interface Route { route: string; metadata: Metadata; } /** Caminhos (`/cases/…`, `/ia`) de todas as URLs de app/sitemap.ts, menos a home, que tem o bloco próprio mais abaixo. */ function sitemapRoutes() { return sitemap() .map(({ url }) => new URL(url).pathname) .filter((route) => route !== "/"); } /** Acha app//page: um trecho sem pasta própria cai na pasta de segmento dinâmico (`[slug]`), e vira parâmetro. */ function resolvePage(route: string) { let dir = __dirname; const params: Record = {}; for (const segment of route.split("/").filter(Boolean)) { if (existsSync(join(dir, segment))) { dir = join(dir, segment); continue; } const dynamic = readdirSync(dir).find((name) => /^\[\w+\]$/.test(name)); if (!dynamic) throw new Error(`${route}: sem pasta em app/ para "${segment}"`); params[dynamic.slice(1, -1)] = segment; dir = join(dir, dynamic); } return { file: join(dir, "page"), params }; } interface PageModule { metadata?: Metadata; generateMetadata?: (props: { params: Promise>; }) => Promise; } async function metadataOf(route: string): Promise { const { file, params } = resolvePage(route); const page = (await import(file)) as PageModule; if (page.generateMetadata) return page.generateMetadata({ params: Promise.resolve(params) }); if (page.metadata) return page.metadata; throw new Error( `${route}: a página não exporta metadata nem generateMetadata`, ); } /** Todas as rotas do sitemap (menos a home) e o 404, que não entra no sitemap. */ async function collectRoutes(): Promise { const pages = await Promise.all( sitemapRoutes().map(async (route) => ({ route, metadata: await metadataOf(route), })), ); return [...pages, { route: "/not-found", metadata: notFoundMetadata }]; } function textOf(value: unknown): string { if (typeof value === "string") return value; throw new Error(`Esperava um texto, recebi ${JSON.stringify(value)}`); } describe("títulos e descrições das rotas do sitemap (menos a home) e do 404", () => { it("cobre cada rota do sitemap, incluindo /ia, e o 404", async () => { const routes = (await collectRoutes()).map(({ route }) => route); expect(sitemapRoutes().length).toBeGreaterThan(15); expect(routes).toEqual([...sitemapRoutes(), "/not-found"]); expect(routes).toContain("/ia"); expect(TITLE_SUFFIX).toHaveLength(17); }); it(`mantém a descrição de /ia em até ${MAX_DESCRIPTION} caracteres`, async () => { const ia = (await collectRoutes()).find(({ route }) => route === "/ia"); const description = textOf(ia?.metadata.description); expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION); expect(description.length).toBeGreaterThanOrEqual(40); }); it(`mantém o título em até ${MAX_META_TITLE} caracteres, ${MAX_FULL_TITLE} com o sufixo do layout`, async () => { const violations = (await collectRoutes()).flatMap( ({ route, metadata }) => { const title = textOf(metadata.title); const full = `${title}${TITLE_SUFFIX}`; return title.length > MAX_META_TITLE || full.length > MAX_FULL_TITLE ? [`${route}: "${title}" tem ${title.length} caracteres`] : []; }, ); expect(violations).toEqual([]); }); it("não repete o nome do site dentro do título (o layout já o acrescenta)", async () => { const violations = (await collectRoutes()).flatMap( ({ route, metadata }) => { const title = textOf(metadata.title); return /\||Evandro\.dev\.br/.test(title) ? [`${route}: ${title}`] : []; }, ); expect(violations).toEqual([]); }); it(`mantém a descrição em até ${MAX_DESCRIPTION} caracteres`, async () => { const violations = (await collectRoutes()).flatMap( ({ route, metadata }) => { const description = textOf(metadata.description); return description.length > MAX_DESCRIPTION || description.length < 40 ? [`${route}: a descrição tem ${description.length} caracteres`] : []; }, ); expect(violations).toEqual([]); }); it("repete no cartão de compartilhamento o título (com ou sem o sufixo do site) e a descrição, dentro dos mesmos limites", async () => { for (const { route, metadata } of await collectRoutes()) { // o 404 não tem cartão próprio: só noindex if (route === "/not-found") continue; const title = textOf(metadata.title); for (const card of [metadata.openGraph, metadata.twitter]) { // o cartão não passa pelo template do layout: a página pode pôr o sufixo na mão (/ia faz isso) expect([title, `${title}${TITLE_SUFFIX}`]).toContain( textOf(card?.title), ); expect(textOf(card?.title).length).toBeLessThanOrEqual(MAX_FULL_TITLE); expect(textOf(card?.description)).toBe(textOf(metadata.description)); } } }); it("não repete título nem descrição entre rotas", async () => { const routes = await collectRoutes(); const titles = routes.map(({ metadata }) => textOf(metadata.title)); const descriptions = routes.map(({ metadata }) => textOf(metadata.description), ); expect(new Set(titles).size).toBe(titles.length); expect(new Set(descriptions).size).toBe(descriptions.length); }); it("dá a cada página a própria canônica, e nenhuma ao 404", async () => { for (const { route, metadata } of await collectRoutes()) { if (route === "/not-found") { expect(metadata.alternates).toEqual({ canonical: null }); } else { expect(metadata.alternates?.canonical).toBe(`${siteUrl}${route}`); } } }); }); describe("título e descrição da home, que moram no layout", () => { // o `title.default` não passa pelo template do layout, então já leva o sufixo const homeTitle = (layoutMetadata.title as { default: string }).default; it(`mantém o título da home em até ${MAX_FULL_TITLE} caracteres, sufixo incluído`, () => { expect(homeTitle.endsWith(TITLE_SUFFIX)).toBe(true); expect(homeTitle.length).toBeLessThanOrEqual(MAX_FULL_TITLE); }); it(`mantém a descrição da home em até ${MAX_DESCRIPTION} caracteres`, () => { const description = textOf(layoutMetadata.description); expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION); expect(description.length).toBeGreaterThanOrEqual(40); }); it("não repete o título nem a descrição da home em outra rota", async () => { const routes = await collectRoutes(); const titles = routes.map(({ metadata }) => textOf(metadata.title)); const descriptions = routes.map(({ metadata }) => textOf(metadata.description), ); expect(titles).not.toContain(homeTitle.slice(0, -TITLE_SUFFIX.length)); expect(descriptions).not.toContain(textOf(layoutMetadata.description)); }); });