feat(seo)unifica a entidade Person por @id e marca os projetos
src/app/blog/[slug]/page.tsxmodificado
@@ -6,6 +6,7 @@ import PostToc from "@/components/PostToc/PostToc";
import RelatedPosts from "@/components/RelatedPosts/RelatedPosts";
import { type PostSlug, postLoaders, postSlugs } from "@/data/posts";
import { getPostToc } from "@/data/postToc";
+import { personRef } from "@/data/schema";
import { pageMetadata, siteConfig } from "@/data/site";
import styles from "./post.module.css";
@@ -78,12 +79,8 @@ const PostPage = async ({ params }: Props) => {
dateModified: meta.updated ?? meta.date,
inLanguage: "pt-BR",
keywords: meta.tags.join(", "),
- author: { "@type": "Person", name: siteConfig.name, url: siteConfig.url },
- publisher: {
- "@type": "Person",
- name: siteConfig.name,
- url: siteConfig.url,
- },
+ author: personRef,
+ publisher: personRef,
mainEntityOfPage: { "@type": "WebPage", "@id": url },
image: new URL(`/blog/${slug}/opengraph-image`, siteConfig.url).toString(),
};src/app/page.tsxmodificado
@@ -4,6 +4,7 @@ import { FaGithub, FaLinkedin } from "react-icons/fa";
import profileImage from "@/assets/images/profile.jpg";
import JsonLd from "@/components/JsonLd/JsonLd";
import PostLinks from "@/components/PostLinks/PostLinks";
+import { personSchema } from "@/data/schema";
import { pageMetadata, siteConfig, socialLinks } from "@/data/site";
import styles from "./page.module.css";
@@ -15,34 +16,6 @@ export const metadata = pageMetadata({
absoluteTitle: true,
});
-const personSchema = {
- "@context": "https://schema.org",
- "@type": "Person",
- name: siteConfig.name,
- alternateName: "Paulo Henrique Rodrigues de Matos",
- url: siteConfig.url,
- image: new URL(profileImage.src, siteConfig.url).toString(),
- jobTitle: "Desenvolvedor Full-Stack",
- description: siteConfig.description,
- worksFor: {
- "@type": "Organization",
- name: "Lyncas",
- url: "https://lyncas.net/",
- },
- sameAs: [socialLinks.linkedin, socialLinks.github],
- knowsAbout: ["C#", "ASP.NET Core", "React", "TypeScript", "Rust"],
- alumniOf: [
- {
- "@type": "CollegeOrUniversity",
- name: "Universidade Federal de Minas Gerais",
- },
- {
- "@type": "CollegeOrUniversity",
- name: "CEFET-MG",
- },
- ],
-};
-
const SOCIAL = [
{ href: socialLinks.linkedin, label: "LinkedIn", Icon: FaLinkedin },
{ href: socialLinks.github, label: "GitHub", Icon: FaGithub },
@@ -71,7 +44,7 @@ const PROJECTS = [
const Home = () => {
return (
<div className={styles.welcome}>
- <JsonLd data={personSchema} />
+ <JsonLd data={personSchema(profileImage.src)} />
<header className={styles.header}>
<Image
src={profileImage}src/app/projetos/aplicativos/ze-chinelao/page.tsxmodificado
@@ -7,16 +7,29 @@ import imgSistemaLinear from "@/assets/images/ze-chinelao/imgSistemaLinear.png";
import imgSistemaLinearCalculo from "@/assets/images/ze-chinelao/imgSistemaLinearCalculo.png";
import imgTelaInicial from "@/assets/images/ze-chinelao/imgTelaInicial.png";
import ImageCarousel from "@/components/ImageCarousel/ImageCarousel";
+import JsonLd from "@/components/JsonLd/JsonLd";
+import { projectSchema } from "@/data/schema";
import { pageMetadata } from "@/data/site";
+const DESCRIPTION =
+ "Aplicativo que resolve divisores, MMC e sistemas lineares exibindo o cálculo passo a passo, pensado para o ensino de matemática.";
+
export const metadata = pageMetadata({
title: "Zé Chinelão: MMC e sistemas lineares passo a passo",
- description:
- "Aplicativo que resolve divisores, MMC e sistemas lineares exibindo o cálculo passo a passo, pensado para o ensino de matemática.",
+ description: DESCRIPTION,
path: "/projetos/aplicativos/ze-chinelao",
absoluteTitle: true,
});
+const schema = projectSchema({
+ name: "Zé Chinelão",
+ description: DESCRIPTION,
+ path: "/projetos/aplicativos/ze-chinelao",
+ codeRepository: "https://github.com/pmatos2000/ze_chinelao",
+ programmingLanguage: ["Java"],
+ keywords: ["Android", "MMC", "sistemas lineares", "ensino de matemática"],
+});
+
const zeChinelaoImages = [
{ src: imgTelaInicial, alt: "Tela inicial do aplicativo Zé Chinelão" },
{
@@ -49,6 +62,7 @@ const zeChinelaoImages = [
const ZeChinelaoPage = () => {
return (
<div className="contentPage">
+ <JsonLd data={schema} />
<h1>Projeto: Zé Chinelão (2016)</h1>
<p>src/app/projetos/jogos/rosa-polar/page.tsxmodificado
@@ -1,16 +1,35 @@
+import JsonLd from "@/components/JsonLd/JsonLd";
+import { projectSchema } from "@/data/schema";
import { pageMetadata } from "@/data/site";
+const DESCRIPTION =
+ "Simulação interativa da curva de Rhodonea (rosa polar) construída na Godot Engine, usando o motor de jogos como laboratório de visualização matemática.";
+
export const metadata = pageMetadata({
title: "Rosa Polar: simulação da curva de Rhodonea em Godot",
- description:
- "Simulação interativa da curva de Rhodonea (rosa polar) construída na Godot Engine, usando o motor de jogos como laboratório de visualização matemática.",
+ description: DESCRIPTION,
path: "/projetos/jogos/rosa-polar",
absoluteTitle: true,
});
+const schema = projectSchema({
+ name: "Rosa Polar",
+ description: DESCRIPTION,
+ path: "/projetos/jogos/rosa-polar",
+ codeRepository:
+ "https://github.com/pmatos2000/godot_study/tree/main/rose-curve",
+ programmingLanguage: ["GDScript"],
+ sameAs: [
+ "https://magolaplace.itch.io/rose-curve",
+ "https://www.youtube.com/watch?v=AKIcIY77HKY",
+ ],
+ keywords: ["Godot Engine", "curva de Rhodonea", "visualização matemática"],
+});
+
const RosaPolarPage = () => {
return (
<div className="contentPage">
+ <JsonLd data={schema} />
<h1>Rosa Polar - Uma Simulação Matemática com Godot</h1>
<p>src/app/projetos/leaf/page.tsxmodificado
@@ -1,16 +1,37 @@
import Link from "next/link";
import Code from "@/components/CodeBlock/Code";
+import JsonLd from "@/components/JsonLd/JsonLd";
import PostLinks from "@/components/PostLinks/PostLinks";
+import { projectSchema } from "@/data/schema";
import { pageMetadata } from "@/data/site";
+const DESCRIPTION =
+ "Linguagem de script tipada para jogos, compilada para bytecode de uma VM de registradores, sem coletor de lixo e sem panic. Por que não usar Lua ou Rhai.";
+
export const metadata = pageMetadata({
title: "Leaf: linguagem de script tipada e sem GC, embarcável em Rust",
- description:
- "Linguagem de script tipada para jogos, compilada para bytecode de uma VM de registradores, sem coletor de lixo e sem panic. Por que não usar Lua ou Rhai.",
+ description: DESCRIPTION,
path: "/projetos/leaf",
absoluteTitle: true,
});
+/**
+ * Sem codeRepository: o compilador ainda não existe, então o tipo cai para
+ * CreativeWork. Vira SoftwareSourceCode no dia em que houver fonte pública.
+ */
+const schema = projectSchema({
+ name: "Leaf",
+ description: DESCRIPTION,
+ path: "/projetos/leaf",
+ keywords: [
+ "linguagem de script",
+ "Rust",
+ "VM de registradores",
+ "tipagem estática",
+ "desenvolvimento de jogos",
+ ],
+});
+
const EXAMPLE = `@implements(Atualizavel)
let vida: int = 100
@@ -42,6 +63,7 @@ let c: int? = a.get(10) // None, nunca panic`;
const LeafPage = () => {
return (
<div className="contentPage">
+ <JsonLd data={schema} />
<h1>Leaf — uma linguagem de script embarcável em Rust</h1>
<p>src/app/projetos/portfolio/page.tsxmodificado
@@ -1,17 +1,31 @@
import Link from "next/link";
+import JsonLd from "@/components/JsonLd/JsonLd";
+import { projectSchema } from "@/data/schema";
import { pageMetadata } from "@/data/site";
+const DESCRIPTION =
+ "Como este site foi construído: a metáfora do editor de código, o que ela custa em SEO, e as decisões de acessibilidade e performance por trás do resultado.";
+
export const metadata = pageMetadata({
title: "Portfólio em Next.js com a interface do VS Code",
- description:
- "Como este site foi construído: a metáfora do editor de código, o que ela custa em SEO, e as decisões de acessibilidade e performance por trás do resultado.",
+ description: DESCRIPTION,
path: "/projetos/portfolio",
absoluteTitle: true,
});
+const schema = projectSchema({
+ name: "Portfólio de Paulo Matos",
+ description: DESCRIPTION,
+ path: "/projetos/portfolio",
+ codeRepository: "https://github.com/pmatos2000/portfolio-paulo-matos",
+ programmingLanguage: ["TypeScript"],
+ keywords: ["Next.js", "React", "TypeScript", "acessibilidade", "SEO"],
+});
+
const PortfolioPage = () => {
return (
<div className="contentPage">
+ <JsonLd data={schema} />
<h1>Portfólio — este site</h1>
<p>src/app/projetos/rustle/page.tsxmodificado
@@ -1,15 +1,28 @@
import Link from "next/link";
import Code from "@/components/CodeBlock/Code";
+import JsonLd from "@/components/JsonLd/JsonLd";
+import { projectSchema } from "@/data/schema";
import { pageMetadata } from "@/data/site";
+const DESCRIPTION =
+ "Engine de jogos 2D em Rust, projetada para renderizar pixels idênticos no nativo e na web. A arquitetura e o que uma prova de conceito de 8 mil linhas ensinou.";
+
export const metadata = pageMetadata({
title: "Rustle: engine de jogos 2D em Rust com a cena como documento JSON",
- description:
- "Engine de jogos 2D em Rust, projetada para renderizar pixels idênticos no nativo e na web. A arquitetura e o que uma prova de conceito de 8 mil linhas ensinou.",
+ description: DESCRIPTION,
path: "/projetos/rustle",
absoluteTitle: true,
});
+const schema = projectSchema({
+ name: "Rustle",
+ description: DESCRIPTION,
+ path: "/projetos/rustle",
+ codeRepository: "https://github.com/pmatos2000/rustle",
+ programmingLanguage: ["Rust"],
+ keywords: ["engine de jogos", "Rust", "WebAssembly", "renderização 2D"],
+});
+
const SCENE = `{
"name": "player",
"tags": ["hero"],
@@ -24,6 +37,7 @@ const SCENE = `{
const RustlePage = () => {
return (
<div className="contentPage">
+ <JsonLd data={schema} />
<h1>Rustle — uma engine de jogos 2D em Rust</h1>
<p>src/data/schema.tsadicionado
@@ -0,0 +1,141 @@
+import { siteConfig, socialLinks } from "@/data/site";
+
+/**
+ * Nó único da entidade Paulo Matos no grafo do site.
+ *
+ * O nó é definido uma vez, pelo personSchema da home. Todo JSON-LD que
+ * precisa citar o autor referencia este @id em vez de declarar outro objeto
+ * Person — sem isso cada página publica um nó solto e o buscador precisa
+ * deduzir que são a mesma pessoa, em vez de ler a relação já declarada.
+ */
+export const PERSON_ID = `${siteConfig.url}/#paulo`;
+
+/** Aponta para o nó da home; não o redeclara. */
+export const personRef = { "@id": PERSON_ID } as const;
+
+/**
+ * Tecnologia como entidade, não como string.
+ *
+ * "Rust" sozinho tanto é a linguagem quanto a ferrugem, e "React" é também
+ * um verbo em inglês. O sameAs para a Wikipédia diz de qual das acepções se
+ * trata, que é o que liga esta Person ao mesmo nó que o buscador já conhece.
+ */
+type Technology = {
+ name: string;
+ sameAs: string;
+};
+
+const WIKIPEDIA = "https://en.wikipedia.org/wiki";
+
+const TECHNOLOGIES: Technology[] = [
+ { name: "C#", sameAs: `${WIKIPEDIA}/C_Sharp_(programming_language)` },
+ { name: "ASP.NET Core", sameAs: `${WIKIPEDIA}/ASP.NET_Core` },
+ { name: "React", sameAs: `${WIKIPEDIA}/React_(software)` },
+ { name: "TypeScript", sameAs: `${WIKIPEDIA}/TypeScript` },
+ { name: "Rust", sameAs: `${WIKIPEDIA}/Rust_(programming_language)` },
+];
+
+const asThing = ({ name, sameAs }: Technology) => ({
+ "@type": "Thing",
+ name,
+ sameAs,
+});
+
+const LOCALITY = "Belo Horizonte";
+
+const ADDRESS = {
+ "@type": "PostalAddress",
+ addressLocality: LOCALITY,
+ addressRegion: "MG",
+ addressCountry: "BR",
+} as const;
+
+/**
+ * A Person do site. Só a home a emite — as demais páginas referenciam.
+ *
+ * A imagem chega por parâmetro porque o caminho vem do import do Next, que
+ * embute o hash do arquivo: escrever o caminho à mão aqui quebraria calado
+ * na primeira vez que a foto mudasse.
+ */
+export const personSchema = (imagePath: string) => ({
+ "@context": "https://schema.org",
+ "@type": "Person",
+ "@id": PERSON_ID,
+ name: siteConfig.name,
+ alternateName: "Paulo Henrique Rodrigues de Matos",
+ url: siteConfig.url,
+ mainEntityOfPage: siteConfig.url,
+ image: new URL(imagePath, siteConfig.url).toString(),
+ jobTitle: "Desenvolvedor Full-Stack",
+ description: siteConfig.description,
+ address: ADDRESS,
+ hasOccupation: {
+ "@type": "Occupation",
+ name: "Desenvolvedor de software",
+ occupationLocation: { "@type": "City", name: LOCALITY },
+ },
+ worksFor: {
+ "@type": "Organization",
+ name: "Lyncas",
+ url: "https://lyncas.net/",
+ },
+ sameAs: [socialLinks.linkedin, socialLinks.github],
+ knowsAbout: TECHNOLOGIES.map(asThing),
+ alumniOf: [
+ {
+ "@type": "CollegeOrUniversity",
+ name: "Universidade Federal de Minas Gerais",
+ },
+ {
+ "@type": "CollegeOrUniversity",
+ name: "CEFET-MG",
+ },
+ ],
+});
+
+type ProjectInput = {
+ name: string;
+ description: string;
+ path: string;
+ /** Ausente enquanto não houver código publicado. */
+ codeRepository?: string;
+ /** Em que o projeto é escrito, não sobre o que ele fala. */
+ programmingLanguage?: string[];
+ /** Onde rodar ou baixar: itch.io, loja, demonstração. */
+ sameAs?: string[];
+ keywords?: string[];
+};
+
+/**
+ * Marca a página de um projeto como obra de autoria da Person.
+ *
+ * É o que tira o knowsAbout do campo da afirmação: dizer "sei Rust" custa
+ * uma linha, mas um SoftwareSourceCode com repositório público, linguagem
+ * declarada e autor apontando para o mesmo @id da home é verificável.
+ *
+ * SoftwareSourceCode pressupõe código publicado. Projeto ainda em
+ * especificação entra como CreativeWork — declarar fonte onde não existe
+ * nenhuma seria falso, e dado estruturado falso é pior que dado ausente.
+ */
+export const projectSchema = ({
+ name,
+ description,
+ path,
+ codeRepository,
+ programmingLanguage,
+ sameAs,
+ keywords,
+}: ProjectInput) => ({
+ "@context": "https://schema.org",
+ "@type": codeRepository ? "SoftwareSourceCode" : "CreativeWork",
+ name,
+ description,
+ url: new URL(path, siteConfig.url).toString(),
+ mainEntityOfPage: new URL(path, siteConfig.url).toString(),
+ author: personRef,
+ inLanguage: "pt-BR",
+ ...(codeRepository ? { codeRepository } : {}),
+ ...(programmingLanguage ? { programmingLanguage } : {}),
+ ...(sameAs ? { sameAs } : {}),
+ ...(keywords ? { keywords: keywords.join(", ") } : {}),
+});