5d48961

← Histórico

feat(seo)unifica a entidade Person por @id e marca os projetos

5d48961+23945Ver no GitHub

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(", ") } : {}),
+});