feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
1 parent
c9a0b80656
commit
e80f73d075
52 files changed
+2593
-439
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import type { Metadata } from "next";
|
||||
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
|
||||
import { generateMetadata as generateCaseMetadata } from "./cases/[slug]/page";
|
||||
import { metadata as casesMetadata } from "./cases/page";
|
||||
import { metadata as curriculoMetadata } from "./curriculo/page";
|
||||
import { metadata as layoutMetadata } from "./layout";
|
||||
import { metadata as notFoundMetadata } from "./not-found";
|
||||
import { metadata as privacyMetadata } from "./privacidade/page";
|
||||
import { generateMetadata as generateServiceMetadata } from "./servicos/[slug]/page";
|
||||
import { metadata as servicesMetadata } from "./servicos/page";
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function collectRoutes(): Promise<Route[]> {
|
||||
const cases = await Promise.all(
|
||||
caseStudies.map(async ({ id }) => ({
|
||||
route: `/cases/${id}`,
|
||||
metadata: await generateCaseMetadata({
|
||||
params: Promise.resolve({ slug: id }),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const services = await Promise.all(
|
||||
servicePages.map(async ({ slug }) => ({
|
||||
route: `/servicos/${slug}`,
|
||||
metadata: await generateServiceMetadata({
|
||||
params: Promise.resolve({ slug }),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
return [
|
||||
{ route: "/cases", metadata: casesMetadata },
|
||||
...cases,
|
||||
{ route: "/servicos", metadata: servicesMetadata },
|
||||
...services,
|
||||
{ route: "/curriculo", metadata: curriculoMetadata },
|
||||
{ route: "/privacidade", metadata: privacyMetadata },
|
||||
{ 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 de cases, serviços, currículo, privacidade e 404", () => {
|
||||
it("cobre as 19 rotas do escopo", async () => {
|
||||
expect(await collectRoutes()).toHaveLength(1 + 8 + 1 + 6 + 1 + 1 + 1);
|
||||
expect(TITLE_SUFFIX).toHaveLength(17);
|
||||
});
|
||||
|
||||
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 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;
|
||||
for (const card of [metadata.openGraph, metadata.twitter]) {
|
||||
expect(textOf(card?.title)).toBe(textOf(metadata.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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user