1d740e3

← Histórico

refactor(blog)guarda a hora de publicação no post e unifica o fuso

1d740e3+13262Ver no GitHub

src/app/blog/[slug]/opengraph-image.tsxmodificado

@@ -1,5 +1,6 @@
 import { ImageResponse } from "next/og";
-import { type PostSlug, postLoaders, postSlugs } from "@/data/posts";
+import { formatPostDate } from "@/data/postDate";
+import { loadPost, type PostSlug, postSlugs } from "@/data/posts";
 import { siteConfig } from "@/data/site";
 
 export const alt = `Post no blog de ${siteConfig.name}`;
@@ -36,21 +37,13 @@ const titleFontSize = (title: string) => {
   return 58;
 };
 
-const formatDate = (iso: string) =>
-  new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "long",
-    year: "numeric",
-    timeZone: "UTC",
-  }).format(new Date(iso));
-
 export default async function Image({
   params,
 }: {
   params: Promise<{ slug: PostSlug }>;
 }) {
   const { slug } = await params;
-  const { meta } = await postLoaders[slug]();
+  const { meta } = await loadPost(slug);
 
   return new ImageResponse(
     <div
@@ -136,7 +129,7 @@ export default async function Image({
             marginTop: 36,
           }}
         >
-          {`${formatDate(meta.date)} · ${siteConfig.name}`}
+          {`${formatPostDate(meta.date)} · ${siteConfig.name}`}
         </div>
         <div
           style={{

src/app/blog/[slug]/page.tsxmodificado

@@ -4,7 +4,8 @@ import JsonLd from "@/components/JsonLd/JsonLd";
 import PostNav from "@/components/PostNav/PostNav";
 import PostToc from "@/components/PostToc/PostToc";
 import RelatedPosts from "@/components/RelatedPosts/RelatedPosts";
-import { type PostSlug, postLoaders, postSlugs } from "@/data/posts";
+import { formatPostDate } from "@/data/postDate";
+import { loadPost, type PostSlug, postSlugs } from "@/data/posts";
 import { getPostToc } from "@/data/postToc";
 import { personRef } from "@/data/schema";
 import { pageMetadata, siteConfig } from "@/data/site";
@@ -18,21 +19,9 @@ export function generateStaticParams() {
   return postSlugs.map((slug) => ({ slug }));
 }
 
-/**
- * UTC explícito: `new Date("2026-08-11")` é meia-noite UTC, e formatar em
- * America/Sao_Paulo (UTC-3) exibiria 10/08.
- */
-const formatDate = (iso: string) =>
-  new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "long",
-    year: "numeric",
-    timeZone: "UTC",
-  }).format(new Date(iso));
-
 export async function generateMetadata({ params }: Props): Promise<Metadata> {
   const { slug } = await params;
-  const { meta } = await postLoaders[slug]();
+  const { meta } = await loadPost(slug);
 
   return pageMetadata({
     title: meta.title,
@@ -49,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
 
 const PostPage = async ({ params }: Props) => {
   const { slug } = await params;
-  const { default: Content, meta } = await postLoaders[slug]();
+  const { default: Content, meta } = await loadPost(slug);
   const toc = await getPostToc(slug);
 
   /**
@@ -91,7 +80,7 @@ const PostPage = async ({ params }: Props) => {
       <article>
         <h1>{meta.title}</h1>
         <div className={styles.postMeta}>
-          <time dateTime={meta.date}>{formatDate(meta.date)}</time>
+          <time dateTime={meta.date}>{formatPostDate(meta.date)}</time>
           <ul className={styles.tags}>
             {meta.tags.map((tag) => (
               <li key={tag} className={styles.tag}>

src/app/blog/page.tsxmodificado

@@ -1,4 +1,5 @@
 import Link from "next/link";
+import { formatPostDate, postYear } from "@/data/postDate";
 import { type LoadedPost, loadPosts } from "@/data/posts";
 import { blogConfig, pageMetadata } from "@/data/site";
 import styles from "./blog.module.css";
@@ -12,7 +13,7 @@ export const metadata = pageMetadata({
 const groupByYear = (posts: LoadedPost[]): [string, LoadedPost[]][] => {
   const years = new Map<string, LoadedPost[]>();
   for (const post of posts) {
-    const year = post.date.slice(0, 4);
+    const year = postYear(post.date);
     const bucket = years.get(year);
     if (bucket) {
       bucket.push(post);
@@ -23,14 +24,6 @@ const groupByYear = (posts: LoadedPost[]): [string, LoadedPost[]][] => {
   return [...years];
 };
 
-const formatDate = (iso: string) =>
-  new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "short",
-    year: "numeric",
-    timeZone: "UTC",
-  }).format(new Date(iso));
-
 const BlogPage = async () => {
   const groups = groupByYear(await loadPosts());
 
@@ -51,7 +44,7 @@ const BlogPage = async () => {
                   <Link href={`/blog/${post.slug}`}>{post.title}</Link>
                 </h3>
                 <time className={styles.date} dateTime={post.date}>
-                  {formatDate(post.date)}
+                  {formatPostDate(post.date, "short")}
                 </time>
                 <p className={styles.description}>{post.description}</p>
               </li>

src/app/sitemap.tsmodificado

@@ -1,5 +1,5 @@
 import type { MetadataRoute } from "next";
-import { postLoaders, postSlugs } from "@/data/posts";
+import { loadPost, postSlugs } from "@/data/posts";
 import { sidebarTree, type TreeItem } from "@/data/sidebarTree";
 import { siteConfig } from "@/data/site";
 
@@ -26,7 +26,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
 
   const posts = await Promise.all(
     postSlugs.map(async (slug) => {
-      const { meta } = await postLoaders[slug]();
+      const { meta } = await loadPost(slug);
       return {
         url: new URL(`/blog/${slug}`, siteConfig.url).toString(),
         lastModified: meta.date,

src/components/PostLinks/PostLinks.tsxmodificado

@@ -1,17 +1,10 @@
 import Link from "next/link";
+import { formatPostDate } from "@/data/postDate";
 import { loadPosts } from "@/data/posts";
 import styles from "./PostLinks.module.css";
 
 type PostLinksProps = { tag?: string; limit?: number };
 
-const formatDate = (iso: string) =>
-  new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "short",
-    year: "numeric",
-    timeZone: "UTC",
-  }).format(new Date(iso));
-
 const PostLinks = async ({ tag, limit }: PostLinksProps) => {
   const all = await loadPosts();
   const posts = all
@@ -29,7 +22,7 @@ const PostLinks = async ({ tag, limit }: PostLinksProps) => {
           <Link href={`/blog/${post.slug}`} className={styles.link}>
             <span className={styles.title}>{post.title}</span>
             <time className={styles.date} dateTime={post.date}>
-              {formatDate(post.date)}
+              {formatPostDate(post.date, "short")}
             </time>
           </Link>
         </li>

src/components/RelatedPosts/RelatedPosts.tsxmodificado

@@ -1,4 +1,5 @@
 import Link from "next/link";
+import { postDay } from "@/data/postDate";
 import { type LoadedPost, loadPosts, type PostSlug } from "@/data/posts";
 import styles from "./RelatedPosts.module.css";
 
@@ -45,7 +46,11 @@ const RelatedPosts = async ({ slug, limit = 3 }: RelatedPostsProps) => {
     .filter((post) => !skip.has(post.slug))
     .map((post) => ({ post, score: scoreOf(post, current, frequency) }))
     .filter((entry) => entry.score > 0)
-    .sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date))
+    .sort(
+      (a, b) =>
+        b.score - a.score ||
+        postDay(b.post.date).localeCompare(postDay(a.post.date)),
+    )
     .slice(0, limit);
 
   if (related.length === 0) {

src/content/blog/o-leaf-por-dentro.mdxmodificado

@@ -2,7 +2,8 @@ export const meta = {
   title: "O Leaf por dentro: as decisões que deram forma à linguagem",
   description:
     "Como o Leaf ficou depois da revisão de agosto: tipos, imutabilidade, self obrigatório, traits com default — e as cinco palavras-chave que tirei da linguagem.",
-  date: "2026-08-12",
+  date: "2026-08-12T20:02:00-03:00",
+  updated: null,
   tags: ["leaf", "design de linguagem", "compiladores"],
 };
 

src/content/blog/o-numero-que-me-faria-mudar-de-ideia.mdxmodificado

@@ -2,7 +2,8 @@ export const meta = {
   title: "O número que me faria mudar de ideia",
   description:
     "Escolhi de propósito a representação de valor mais lenta da VM do Leaf, e escrevi no plano, antes de qualquer medição, o número exato que autorizaria trocar.",
-  date: "2026-08-12",
+  date: "2026-08-12T20:02:00-03:00",
+  updated: null,
   tags: ["leaf", "rust", "compiladores"],
 };
 

src/content/blog/o-plano-em-27-etapas.mdxmodificado

@@ -2,7 +2,8 @@ export const meta = {
   title: "O plano do Leaf em 27 etapas",
   description:
     "Nenhuma linha do compilador existe ainda. O que existe é o plano: de onde cada etapa sai, onde ela chega, e como eu vou saber que ela acabou.",
-  date: "2026-08-12",
+  date: "2026-08-12T20:02:00-03:00",
+  updated: null,
   tags: ["leaf", "planejamento", "compiladores"],
 };
 

src/content/blog/por-que-uma-linguagem-de-script.mdxmodificado

@@ -2,7 +2,8 @@ export const meta = {
   title: "Por que eu decidi escrever uma linguagem de script",
   description:
     "A página do Leaf vai mudar até não parecer o que é hoje. Este post não: é o registro de por que a linguagem existe e das cinco que avaliei antes de escrevê-la.",
-  date: "2026-08-12",
+  date: "2026-08-12T20:02:00-03:00",
+  updated: null,
   tags: ["leaf", "rustle", "design de linguagem"],
 };
 

src/content/blog/recusado-nao-e-adiado.mdxmodificado

@@ -2,7 +2,8 @@ export const meta = {
   title: "Recusado não é adiado, e remover é mais difícil que os dois",
   description:
     "Toda lista de fora-da-v1 costuma significar não deu tempo. No Leaf há três listas: o que é v2, o que nunca vem — e o que já estava escrito e saiu.",
-  date: "2026-08-12",
+  date: "2026-08-12T20:02:00-03:00",
+  updated: null,
   tags: ["leaf", "design de linguagem"],
 };
 

src/data/blogTree.tsmodificado

@@ -1,3 +1,4 @@
+import { postYear } from "@/data/postDate";
 import { loadPosts } from "@/data/posts";
 import { getPostToc, type TocHeading } from "@/data/postToc";
 
@@ -22,7 +23,7 @@ export const getBlogYears = async (): Promise<BlogYear[]> => {
   const years = new Map<string, BlogPostRef[]>();
 
   for (const post of await loadPosts()) {
-    const year = post.date.slice(0, 4);
+    const year = postYear(post.date);
     const slug = String(post.slug);
     const ref = {
       slug,

src/data/postDate.tsadicionado

@@ -0,0 +1,40 @@
+/**
+ * Fuso em que as datas do blog são escritas.
+ *
+ * Os posts guardam a hora de Brasília com o deslocamento junto
+ * ("2026-08-12T20:02:00-03:00"), então formatar aqui é só reafirmar o mesmo
+ * fuso — o que garante que servidor e navegador exibam o mesmo dia,
+ * independentemente de onde a página for renderizada.
+ *
+ * O Brasil não usa horário de verão desde 2019, mas quem converte é a base
+ * de fusos do runtime: se um dia voltar, as datas antigas continuam certas
+ * porque cada uma carrega o deslocamento que valia quando foi escrita.
+ */
+export const TIME_ZONE = "America/Sao_Paulo";
+
+/**
+ * A parte do calendário, sem hora. Recortar a string funciona — e é melhor
+ * que converter para Date — porque o texto já está no fuso de publicação:
+ * "2026-08-12T20:02:00-03:00" começa exatamente pelo dia que se quer.
+ */
+export const postDay = (date: string) => date.slice(0, 10);
+
+/** O ano, pela mesma razão do postDay. */
+export const postYear = (date: string) => date.slice(0, 4);
+
+/**
+ * Data de post por extenso. Existe para que os quatro lugares que exibem
+ * data — post, índice, lista da home e imagem de compartilhamento — não
+ * divirjam de fuso, que foi o que aconteceu enquanto cada um tinha a sua
+ * própria cópia do formatador.
+ */
+export const formatPostDate = (
+  date: string,
+  month: "short" | "long" = "long",
+) =>
+  new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month,
+    year: "numeric",
+    timeZone: TIME_ZONE,
+  }).format(new Date(date));

src/data/posts.tsmodificado

@@ -1,12 +1,26 @@
 import type { MDXContent } from "mdx/types";
+import { postDay } from "@/data/postDate";
 
 export type PostMeta = {
   title: string;
   description: string;
-  /** ISO 8601, ex.: "2026-08-11" */
+  /**
+   * ISO 8601 com hora e fuso de Brasília: "2026-08-12T20:02:00-03:00".
+   *
+   * A hora fica junto do dia, e não é acrescentada na hora de renderizar,
+   * porque quem sabe quando o post saiu é o post. Daqui saem o
+   * `datePublished` do JSON-LD, o `pubDate` do RSS e o `lastModified` do
+   * sitemap — todos exigem data completa, e nenhum deles precisa adivinhar.
+   */
   date: string;
-  /** ISO 8601. Ausente = nunca editado depois de publicado. */
-  updated?: string;
+  /**
+   * Mesmo formato do `date`. `null` = nunca editado depois de publicado.
+   *
+   * Obrigatório e anulável, em vez de opcional: escrever `null` é uma
+   * decisão, esquecer o campo é um descuido, e só o primeiro se distingue do
+   * outro na hora de reler o arquivo.
+   */
+  updated: string | null;
   /** Exibição e `keywords` do JSON-LD. Ainda não gera rota. */
   tags: string[];
 };
@@ -42,13 +56,34 @@ export const postSlugs = Object.keys(postLoaders) as PostSlug[];
 
 export type LoadedPost = PostMeta & { slug: PostSlug };
 
+/**
+ * Carrega um post pelo tipo declarado, e não pelo inferido do arquivo.
+ *
+ * Importar um .mdx traz o `meta` com o formato literal daquele arquivo — só
+ * os campos que estão lá escritos. Quem lesse um campo ausente em um post
+ * qualquer recebia erro de compilação em si mesmo, longe da causa; foi o que
+ * aconteceu com `updated`. Anotar o retorno como PostModule alarga para
+ * PostMeta, e um post incompleto passa a acusar aqui, ao lado do postLoaders.
+ */
+export const loadPost = (slug: PostSlug): Promise<PostModule> =>
+  postLoaders[slug]();
+
 /** Todos os posts, do mais novo para o mais antigo. Fonte de toda listagem. */
 export const loadPosts = async (): Promise<LoadedPost[]> => {
   const posts = await Promise.all(
     postSlugs.map(async (slug) => {
-      const { meta } = await postLoaders[slug]();
+      const { meta } = await loadPost(slug);
       return { ...meta, slug };
     }),
   );
-  return posts.sort((a, b) => b.date.localeCompare(a.date));
+  /**
+   * Ordena pelo dia, não pelo instante.
+   *
+   * Comparar a data completa reordenaria os posts do mesmo dia pela hora, e
+   * como a ordenação é decrescente a série do Leaf apareceria de trás para
+   * frente. Recortando o dia, o empate persiste e o desempate continua sendo
+   * a ordem de `postLoaders` — porque `sort` é estável. A hora fica livre
+   * para ser a verdade sobre a publicação sem mexer na leitura.
+   */
+  return posts.sort((a, b) => postDay(b.date).localeCompare(postDay(a.date)));
 };

src/data/schema.tsmodificado

@@ -10,8 +10,24 @@ import { siteConfig, socialLinks } from "@/data/site";
  */
 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;
+/**
+ * Referência ao nó da home, com o mínimo repetido junto.
+ *
+ * Só o @id seria o correto pela especificação, e foi o que estava aqui. Mas
+ * o validador do Google avalia cada página isolada: ele não busca a home
+ * para descobrir quem é esse @id, e reportava author/publisher sem nome nem
+ * url — chegava a inferir "Thing" no lugar de "Person".
+ *
+ * Repetir @type, name e url não cria um segundo nó: o @id é o mesmo, então
+ * quem monta o grafo funde as duas descrições. O que ainda não se pode fazer
+ * é omitir o @id — era isso que fragmentava a entidade.
+ */
+export const personRef = {
+  "@id": PERSON_ID,
+  "@type": "Person",
+  name: siteConfig.name,
+  url: siteConfig.url,
+} as const;
 
 /**
  * Tecnologia como entidade, não como string.