199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
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/<rota>/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<string, string> = {};
|
|
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<Record<string, string>>;
|
|
}) => Promise<Metadata>;
|
|
}
|
|
|
|
async function metadataOf(route: string): Promise<Metadata> {
|
|
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<Route[]> {
|
|
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));
|
|
});
|
|
});
|