feat: add SEO and sitemap functionality
- Implemented robots.txt to guide search engine indexing. - Created layout components for the selector and series pages to manage metadata. - Developed a dynamic sitemap generator based on existing series and frameworks data. - Centralized SEO information in a new lib/seo.js file to maintain consistency across the site. - Added SVG logos for branding.
This commit is contained in:
1 parent
3de4f2501d
commit
37971056c2
74 files changed
+563
-41337
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* A página desta rota é um client component, e client component não pode
|
||||
* exportar metadata. Sem este layout, ela herdaria o título genérico do site,
|
||||
* e o Google veria várias páginas com o mesmo título e a mesma descrição.
|
||||
*/
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Catálogo de placas ESP32",
|
||||
descricao: "Placas de desenvolvimento com ESP32 disponíveis no Brasil, com filtro por série, conector e conversor USB-serial.",
|
||||
caminho: "/catalogo",
|
||||
keywords: ["placas ESP32", "DevKit ESP32", "comprar ESP32", "ESP32 DevKitC"],
|
||||
});
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* A página desta rota é um client component, e client component não pode
|
||||
* exportar metadata. Sem este layout, ela herdaria o título genérico do site,
|
||||
* e o Google veria várias páginas com o mesmo título e a mesma descrição.
|
||||
*/
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Comparar séries ESP32",
|
||||
descricao: "Compare até quatro séries de ESP32 lado a lado: processador, Wi-Fi, Bluetooth, memória, GPIO, periféricos e segurança em hardware, com os dados dos datasheets oficiais.",
|
||||
caminho: "/comparacao",
|
||||
keywords: ["comparar ESP32", "ESP32 vs ESP32-S3", "tabela comparativa ESP32", "qual ESP32 é melhor"],
|
||||
});
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
@@ -261,12 +262,22 @@ export async function generateMetadata({ params }) {
|
||||
const framework = frameworksData[key];
|
||||
|
||||
if (!framework) {
|
||||
return { title: "Framework não encontrado" };
|
||||
return { title: "Framework não encontrado", robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `ESPDocs - ${framework.nome_completo}`,
|
||||
description: framework.descricao,
|
||||
...paginaMeta({
|
||||
titulo: `${framework.nome} para ESP32`,
|
||||
descricao: `${framework.nome_completo}: ${framework.descricao} Guia em português com exemplo de código e quando usar.`,
|
||||
caminho: `/frameworks/${encodeURIComponent(key)}`,
|
||||
tipo: "article",
|
||||
keywords: [
|
||||
framework.nome, framework.nome_completo,
|
||||
`${framework.nome} ESP32`, `programar ESP32 com ${framework.nome}`,
|
||||
"ESP32", "framework ESP32",
|
||||
],
|
||||
}),
|
||||
other: { "article:section": "Frameworks" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import Link from "next/link";
|
||||
@@ -19,6 +20,14 @@ import {
|
||||
Home
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Frameworks para ESP32",
|
||||
descricao: "ESP-IDF, Arduino, MicroPython, ESP-ADF, ESP-DSP, ESP-WHO, ESP-NN e ESP-Matter: para que serve cada framework do ESP32, com exemplo de código e quando usar.",
|
||||
caminho: "/frameworks",
|
||||
keywords: ["ESP-IDF", "Arduino ESP32", "MicroPython ESP32", "frameworks ESP32", "programar ESP32"],
|
||||
});
|
||||
|
||||
export default function Frameworks() {
|
||||
const frameworks = Object.entries(frameworksData);
|
||||
|
||||
|
||||
+15
-4
@@ -1,11 +1,15 @@
|
||||
import { paginaMeta, jsonLd } from "@/lib/seo";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = {
|
||||
title: "ESPDocs - Glossário Técnico",
|
||||
description: "Glossário técnico em português com termos e conceitos do ecossistema ESP32",
|
||||
};
|
||||
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Glossário técnico do ESP32",
|
||||
descricao: "Mais de 40 termos do ecossistema ESP32 explicados em português: GPIO, strapping, ULP, PSRAM, eFuse, deep sleep, OTA, Secure Boot, ECDSA, TWAI, Matter e Thread.",
|
||||
caminho: "/glossario",
|
||||
keywords: ["glossário ESP32", "o que é GPIO", "o que é strapping", "o que é ULP", "termos ESP32"],
|
||||
});
|
||||
|
||||
export default function Glossario() {
|
||||
const categorias = [
|
||||
@@ -364,6 +368,13 @@ export default function Glossario() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-slate-100 via-slate-50 to-purple-100/40 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 transition-colors">
|
||||
{/* Cada verbete vira um DefinedTerm: ajuda o Google a responder
|
||||
buscas do tipo "o que é strapping ESP32" apontando para cá. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.glossario(categorias)) }}
|
||||
/>
|
||||
|
||||
<Header />
|
||||
|
||||
<main id="conteudo" className="px-4 pt-16 pb-20 max-w-7xl mx-auto">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ESPDocs">
|
||||
<title>ESPDocs</title>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="6" y1="58" x2="58" y2="6" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#3b82f6"/>
|
||||
<stop offset="0.55" stop-color="#8b5cf6"/>
|
||||
<stop offset="1" stop-color="#ec4899"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Pinos entrando pela esquerda: o sinal que chega do silício. -->
|
||||
<g stroke="url(#g)" stroke-width="3.4" stroke-linecap="round">
|
||||
<path d="M2 21h7"/>
|
||||
<path d="M2 32h7"/>
|
||||
<path d="M2 43h7"/>
|
||||
</g>
|
||||
|
||||
<!-- Corpo do encapsulamento. -->
|
||||
<rect x="9.25" y="9.25" width="45.5" height="45.5" rx="13"
|
||||
fill="none" stroke="url(#g)" stroke-width="4.5"/>
|
||||
|
||||
<!-- Os mesmos três níveis viram linhas de texto: a documentação.
|
||||
Comprimento decrescente para ler como parágrafo. -->
|
||||
<g stroke="url(#g)" stroke-width="4.5" stroke-linecap="round">
|
||||
<path d="M19 21h26"/>
|
||||
<path d="M19 32h21"/>
|
||||
<path d="M19 43h15"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
+76
-5
@@ -1,28 +1,99 @@
|
||||
import { Outfit, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "@/components/Providers";
|
||||
import { SITE, jsonLd } from "@/lib/seo";
|
||||
|
||||
const outfit = Outfit({
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-outfit",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "ESPDocs",
|
||||
description: "Documentação não oficial do ESP32 em português.",
|
||||
authors: { name: "Lucas Rayan Guerra", url: "https://cienciaembarcada.com.br" },
|
||||
// metadataBase é o que transforma caminhos relativos em URLs absolutas nas
|
||||
// tags canonical e og:image. Sem ele, o Google recebe og:image quebrado.
|
||||
metadataBase: new URL(SITE.url),
|
||||
|
||||
title: {
|
||||
default: "ESPDocs · Documentação do ESP32 em português",
|
||||
// Cada página fornece só o próprio nome; o sufixo entra automaticamente.
|
||||
template: "%s · ESPDocs",
|
||||
},
|
||||
description:
|
||||
"Especificações, pinagem, comparação e seleção das 12 séries de microcontroladores ESP32, em português brasileiro. Datasheets, periféricos, segurança em hardware e diagramas de conexão.",
|
||||
|
||||
applicationName: "ESPDocs",
|
||||
authors: [{ name: "Lucas Rayan Guerra", url: "https://lucasrguerra.dev.br" }],
|
||||
creator: "Lucas Rayan Guerra",
|
||||
publisher: "Ciência Embarcada",
|
||||
category: "technology",
|
||||
|
||||
keywords: [
|
||||
"ESP32", "ESP32 português", "documentação ESP32", "pinout ESP32",
|
||||
"ESP32-S3", "ESP32-C3", "ESP32-C6", "ESP32-P4", "ESP32-H2",
|
||||
"comparar ESP32", "qual ESP32 escolher", "datasheet ESP32",
|
||||
"microcontrolador", "Espressif", "ESP-IDF", "IoT", "sistemas embarcados",
|
||||
],
|
||||
|
||||
alternates: { canonical: "/" },
|
||||
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "pt_BR",
|
||||
url: SITE.url,
|
||||
siteName: "ESPDocs",
|
||||
title: "ESPDocs · Documentação do ESP32 em português",
|
||||
description:
|
||||
"Especificações, pinagem, comparação e seleção das 12 séries de microcontroladores ESP32, em português brasileiro.",
|
||||
},
|
||||
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "ESPDocs · Documentação do ESP32 em português",
|
||||
description:
|
||||
"Especificações, pinagem, comparação e seleção das 12 séries de ESP32, em português.",
|
||||
},
|
||||
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
},
|
||||
|
||||
icons: { icon: "/logo.png", apple: "/logo.png" },
|
||||
formatDetection: { telephone: false, address: false, email: false },
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f8fafc" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#020617" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="pt-BR" className={`${outfit.variable} ${inter.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Dados estruturados do site e da organização. É o que permite ao
|
||||
Google montar o box de busca interna e entender a autoria. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.siteESociedade()) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans antialiased bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50 transition-colors duration-300" suppressHydrationWarning>
|
||||
<Providers>
|
||||
{children}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,20 @@
|
||||
import { SITE } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* robots.txt. O essencial aqui é apontar o sitemap: é assim que o Google
|
||||
* descobre as páginas de série sem depender de rastrear link por link.
|
||||
*/
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// A rota de API só serve ao marketplace e não tem conteúdo indexável.
|
||||
disallow: ["/api/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE.url}/sitemap.xml`,
|
||||
host: SITE.url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* A página desta rota é um client component, e client component não pode
|
||||
* exportar metadata. Sem este layout, ela herdaria o título genérico do site,
|
||||
* e o Google veria várias páginas com o mesmo título e a mesma descrição.
|
||||
*/
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Qual ESP32 escolher",
|
||||
descricao: "Responda seis perguntas sobre conectividade, energia, periféricos e segurança e receba a série de ESP32 recomendada para o seu projeto, com a justificativa técnica de cada ponto.",
|
||||
caminho: "/seletor",
|
||||
keywords: ["qual ESP32 escolher", "qual ESP32 usar", "escolher microcontrolador", "ESP32 para meu projeto"],
|
||||
});
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paginaMeta, jsonLd } from "@/lib/seo";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import Header from "@/components/Header";
|
||||
@@ -376,6 +377,22 @@ export default async function SerieDetail({ params }) {
|
||||
</div>
|
||||
|
||||
{/* DevBoard and Connections Tabs */}
|
||||
{/* Ficha do produto e trilha de navegação para o Google. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.serie(key, serie)) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd.trilha([
|
||||
{ nome: "Início", caminho: "/" },
|
||||
{ nome: "Séries", caminho: "/series" },
|
||||
{ nome: serie.nome_completo, caminho: `/series/${key}` },
|
||||
])),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SeriesTabMenu tabs={tabs} color={serie.cor} />
|
||||
|
||||
<PinRestrictions conexoes={conexoes} serieKey={key} cor={serie.cor} />
|
||||
@@ -542,15 +559,51 @@ export async function generateMetadata({ params }) {
|
||||
const serie = seriesData[key];
|
||||
|
||||
if (!serie) {
|
||||
return {
|
||||
title: "Série não encontrada",
|
||||
};
|
||||
return { title: "Série não encontrada", robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `ESPDocs - ${serie.nome_completo}`,
|
||||
description: serie.descricao,
|
||||
// A descrição usa as especificações reais em vez de texto genérico: é o que
|
||||
// aparece no resultado de busca e o que faz alguém clicar.
|
||||
const radio = [
|
||||
String(serie.wifi).startsWith("Não") ? null : serie.wifi.split(" (")[0],
|
||||
String(serie.bluetooth).startsWith("Não") ? null : serie.bluetooth.split(" (")[0],
|
||||
serie.zigbee_thread && !String(serie.zigbee_thread).startsWith("Não") ? "802.15.4" : null,
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
const descricao =
|
||||
`${serie.nome_completo}: ${serie.arquitetura}, ${serie.frequencia.replace("Até ", "até ")}, ` +
|
||||
`${serie.memoria_sram} de SRAM e ${serie.gpio} GPIOs` +
|
||||
(radio ? `. Rádio: ${radio}` : "") +
|
||||
`. Pinagem completa, periféricos, segurança em hardware e pinos com restrição, em português.`;
|
||||
|
||||
// A pinagem da placa é a melhor prévia possível para uma série: quem
|
||||
// compartilha o link já mostra o diagrama. Séries sem placa publicada caem
|
||||
// na imagem padrão do site.
|
||||
// Definir openGraph explicitamente impede a herança automática da imagem do
|
||||
// site, então séries sem placa publicada (o H4) precisam do fallback aqui.
|
||||
const imagem = serie.placa
|
||||
? [{ url: serie.placa, width: 1600, height: 1091, alt: `Pinagem do ${serie.nome_completo}` }]
|
||||
: [{ url: "/opengraph-image.png", width: 1200, height: 630, alt: "ESPDocs" }];
|
||||
|
||||
const meta = {
|
||||
...paginaMeta({
|
||||
titulo: `${serie.nome_completo}: especificações e pinagem`,
|
||||
descricao,
|
||||
caminho: `/series/${key}`,
|
||||
tipo: "article",
|
||||
keywords: [
|
||||
serie.nome_completo, `${serie.nome_completo} pinout`,
|
||||
`${serie.nome_completo} datasheet`, `${serie.nome_completo} especificações`,
|
||||
`${serie.nome_completo} português`, "ESP32", "Espressif", "microcontrolador",
|
||||
],
|
||||
}),
|
||||
other: { "article:section": "Séries ESP32" },
|
||||
};
|
||||
|
||||
meta.openGraph = { ...meta.openGraph, images: imagem };
|
||||
meta.twitter = { ...meta.twitter, images: imagem };
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* A página desta rota é um client component, e client component não pode
|
||||
* exportar metadata. Sem este layout, ela herdaria o título genérico do site,
|
||||
* e o Google veria várias páginas com o mesmo título e a mesma descrição.
|
||||
*/
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Séries ESP32",
|
||||
descricao: "As 12 séries de ESP32 lado a lado: ESP32, S2, S3, S31, C2, C3, C5, C6, C61, P4, H2 e H4. Arquitetura, memória, rádio, periféricos e pinagem de cada uma, em português.",
|
||||
caminho: "/series",
|
||||
keywords: ["séries ESP32", "modelos de ESP32", "ESP32 S3", "ESP32 C6", "ESP32 P4", "diferença entre ESP32"],
|
||||
});
|
||||
|
||||
// Um layout que define title como string simples substitui o template do root
|
||||
// para toda a subárvore, e as páginas de série perdiam o sufixo "· ESPDocs".
|
||||
// Redeclarar o template aqui devolve o comportamento às filhas.
|
||||
metadata.title = { default: "Séries ESP32", template: "%s · ESPDocs" };
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import seriesData from "@/public/series.json";
|
||||
import frameworksData from "@/public/frameworks.json";
|
||||
import { SITE } from "@/lib/seo";
|
||||
|
||||
/**
|
||||
* Sitemap gerado a partir dos próprios dados do site.
|
||||
*
|
||||
* Antes não existia sitemap nenhum, então o Google só chegava às páginas de
|
||||
* série e de framework se topasse com um link. Como são geradas a partir de
|
||||
* JSON, uma série nova entra aqui sozinha.
|
||||
*/
|
||||
export default function sitemap() {
|
||||
const agora = new Date();
|
||||
|
||||
const fixas = [
|
||||
{ caminho: "", prioridade: 1.0, frequencia: "weekly" },
|
||||
{ caminho: "/series", prioridade: 0.9, frequencia: "weekly" },
|
||||
{ caminho: "/comparacao", prioridade: 0.9, frequencia: "weekly" },
|
||||
{ caminho: "/seletor", prioridade: 0.8, frequencia: "monthly" },
|
||||
{ caminho: "/frameworks", prioridade: 0.8, frequencia: "monthly" },
|
||||
{ caminho: "/glossario", prioridade: 0.7, frequencia: "monthly" },
|
||||
{ caminho: "/catalogo", prioridade: 0.6, frequencia: "weekly" },
|
||||
{ caminho: "/sobre", prioridade: 0.4, frequencia: "yearly" },
|
||||
];
|
||||
|
||||
const series = Object.keys(seriesData).map((chave) => ({
|
||||
caminho: `/series/${chave}`,
|
||||
prioridade: 0.9,
|
||||
frequencia: "monthly",
|
||||
}));
|
||||
|
||||
const frameworks = Object.keys(frameworksData).map((chave) => ({
|
||||
caminho: `/frameworks/${encodeURIComponent(chave)}`,
|
||||
prioridade: 0.6,
|
||||
frequencia: "monthly",
|
||||
}));
|
||||
|
||||
return [...fixas, ...series, ...frameworks].map(({ caminho, prioridade, frequencia }) => ({
|
||||
url: `${SITE.url}${caminho}`,
|
||||
lastModified: agora,
|
||||
changeFrequency: frequencia,
|
||||
priority: prioridade,
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paginaMeta } from "@/lib/seo";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import Link from "next/link";
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
Layers
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
export const metadata = paginaMeta({
|
||||
titulo: "Sobre o projeto",
|
||||
descricao: "O ESPDocs é uma plataforma independente que reúne a documentação do ecossistema ESP32 em português brasileiro. Saiba como o projeto nasceu e como contribuir.",
|
||||
caminho: "/sobre",
|
||||
keywords: ["ESPDocs", "documentação ESP32 português", "projeto open source ESP32"],
|
||||
});
|
||||
|
||||
export default function Sobre() {
|
||||
const features = [
|
||||
{ icon: <Languages className="w-6 h-6 text-purple-500" />, title: "Interface em Português", desc: "Organização e apresentação de dados técnicos totalmente em português." },
|
||||
|
||||
Reference in new issue
Block a user