refactor(blog)guarda a hora de publicação no post e unifica o fuso
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.