Todo projeto é uma obra contínua...

Git, APIs, CI/CD, localization, infraestrutura e deployment devem sempre se complementar.

GithubGithubApiBlog

De um Blog Pessoal a um Pequeno Sistema de Publicação

Quando comecei a construir este site, a ideia era bem simples.

Eu queria um lugar para escrever.

Só isso.

Queria documentar as coisas que estava construindo, o que estava aprendendo, alguns experimentos, projetos open source e, ocasionalmente, coisas que não tinham absolutamente nada a ver com software.

Não queria começar com um CMS.

Não queria um banco de dados.

Não queria construir uma plataforma de publicação.

E, naturalmente, acabei construindo algo que se parece suspeitosamente com uma.

Não foi planejado.

Aconteceu uma decisão arquitetural de cada vez.

Tudo começou com Markdown

A primeira versão do site era deliberadamente simples.

O conteúdo vivia diretamente no repositório como arquivos MDX, organizados por tipo e slug:

text
content/
  blog/
    some-post/
      en.mdx
      pt.mdx
  notes/
    some-note/
      en.mdx
      pt.mdx

Para um site pessoal, essa estrutura funcionava muito bem.

O conteúdo tinha controle de versão. Os artigos eram portáveis. O próprio repositório era, na prática, o banco de dados do conteúdo.

E como o site era construído com Next.js, os arquivos podiam ser interpretados durante o build e incluídos diretamente na aplicação.

Havia algo bastante satisfatório naquela simplicidade.

Escrever um arquivo .mdx.

Fazer commit.

Fazer deploy.

Pronto.

Até eu querer editar o conteúdo diretamente pelo próprio site.

Foi aí que as coisas começaram a ficar interessantes.

Quando autenticação entrou em cena

Antes que pudesse existir uma interface administrativa, precisava existir uma interface administrativa que somente eu pudesse acessar.

Então a autenticação entrou no projeto.

Como o projeto já possuía integração com GitHub, usar o GitHub como provedor de autenticação fazia sentido.

A autenticação resolveria o problema de distinguir um administrador de um visitante comum.

Mas havia algo mais interessante acontecendo.

Se o GitHub já fazia parte da aplicação, talvez ele pudesse fazer mais do que simplesmente autenticar usuários.

Talvez pudesse fazer parte da própria infraestrutura de publicação.

Isso me levou a uma pergunta bastante simples:

Se o conteúdo já vive no Git, por que eu precisaria de outra camada de persistência?

Eu não tinha uma boa resposta.

Então, em vez de introduzir um banco de dados, decidi usar o próprio GitHub como camada de persistência para as operações administrativas de conteúdo.

Essa decisão acabou moldando praticamente toda a arquitetura de publicação.

O GitHub se tornou parte do sistema de publicação

Uma vez que o GitHub estava envolvido, várias possibilidades passaram a existir através de sua API.

A aplicação poderia criar, atualizar, excluir e inspecionar arquivos. Poderia recuperar o histórico de commits e identificar as mudanças associadas às operações de publicação.

Isso significava que o Admin Dashboard não precisava funcionar como um CMS tradicional apoiado por um banco de dados.

Ele poderia funcionar como uma interface controlada para manipular o próprio repositório.

O fluxo passou a ser aproximadamente:

text
Admin Dashboard

Content Editor

Content API

Content Layer

GitHub Contents API

Repository

E depois:

text
GitHub

repository change

Vercel

new deployment

new production build

Em outras palavras:

Admin Dashboard → GitHub → Vercel → New Production Build

E foi aí que meu simples blog começou a se parecer com um pequeno sistema de publicação.

O conteúdo também precisou evoluir

O modelo original já havia sido pensado para suportar localização.

Um post não era simplesmente:

text
post.mdx

Era um documento lógico que poderia possuir múltiplas traduções:

text
content/
  blog/
    my-post/
      en.mdx
      pt.mdx

Essa distinção ficou especialmente importante quando comecei a adicionar edição.

O editor não está realmente editando um arquivo en.mdx.

Ele está editando um documento que pode possuir uma tradução em inglês, uma em português ou ambas.

A aplicação precisava representar essa diferença:

text
ContentDocument

      ├── en
      │    └── content/blog/my-post/en.mdx

      └── pt
           └── content/blog/my-post/pt.mdx

Depois, a camada de publicação transforma esse modelo novamente em operações sobre arquivos do repositório.

Cada tradução se torna uma operação independente.

Em teoria, parece simples.

Na prática, isso abriu espaço para outro problema importante.

Separando o domínio do conteúdo do GitHub

Uma das refatorações mais importantes foi perceber que lib/content não deveria saber como o GitHub funciona.

Inicialmente, essas responsabilidades estavam começando a se misturar.

A camada de conteúdo conhecia os diretórios, os caminhos do filesystem, URLs do GitHub, configuração do repositório e operações da API.

Funcionava.

Até que deixou de funcionar.

O problema não era apenas técnico. Era conceitual.

Existem dois tipos completamente diferentes de caminhos nesse sistema.

Um caminho local poderia ser:

text
D:/alexandre-blog/content/blog/my-post/en.mdx

Enquanto um caminho dentro do repositório deveria ser:

text
content/blog/my-post/en.mdx

Eles parecem relacionados.

Mas não são a mesma coisa.

E definitivamente não deveriam ser tratados como a mesma coisa por uma API.

Então a arquitetura foi dividida.

lib/content

O domínio do conteúdo ficou responsável por:

  • ler MDX;
  • interpretar frontmatter;
  • construir documentos;
  • lidar com traduções;
  • serializar conteúdo;
  • determinar caminhos locais.

lib/github/content

A integração com GitHub ficou responsável por:

  • configuração do repositório;
  • autenticação;
  • URLs da API;
  • caminhos relativos ao repositório;
  • busca de arquivos;
  • criação;
  • atualização;
  • exclusão;
  • upsert.

A fronteira passou a ser muito mais clara:

text
Content domain

Repository operation

GitHub integration

GitHub API

O sistema de conteúdo sabe que precisa persistir um documento.

Ele não precisa saber que essa persistência acontece através da GitHub Contents API.

E essa separação acabou sendo importante por outro motivo.

O problema do filesystem no Vercel

Localmente, é muito fácil pensar no filesystem como a fonte da verdade.

Você pode ler:

text
content/blog/my-post/en.mdx

Pode modificar o arquivo.

Pode executar Git.

Tudo parece normal.

Um ambiente serverless é diferente.

O filesystem disponível para a aplicação não deve ser tratado como um armazenamento persistente de conteúdo.

Isso significa que não podemos simplesmente fazer:

text
writeFile(...)

e esperar que o artigo passe a fazer parte do repositório.

Tentar tratar o ambiente de deployment como uma working tree tradicional do Git criaria ainda outra classe de problemas.

Então o modelo de persistência ficou explícito:

Podemos ler o conteúdo localmente quando fizer sentido, mas mutações administrativas devem ser persistidas através do GitHub.

O repositório passou a ser a fonte de verdade durável.

Publicar significa criar um commit

A parte interessante é que as operações reais de publicação são relativamente pequenas.

Para criar uma nova tradução, a aplicação gera o caminho:

text
content/blog/my-post/en.mdx

Serializa o MDX e o frontmatter e envia o arquivo para o GitHub.

Para atualizar um arquivo existente, o GitHub exige o SHA atual.

Então o fluxo de atualização é aproximadamente:

text
Editor

Find existing file

Get SHA

Serialize updated content

Update file

Create Git commit

Para criar um novo arquivo:

text
Editor

Check whether file exists

Serialize content

Create file

Create Git commit

E existe uma consequência interessante dessa abordagem.

Criar um post cria um commit.

Salvar uma tradução cria um commit.

Atualizar um documento cria um commit.

Excluir um documento cria um commit.

O GitHub deixa de ser simplesmente o lugar onde o código vive.

Ele passa a ser também o histórico do sistema de publicação.

E isso levantou outra pergunta:

Se o GitHub já sabe tudo o que aconteceu, por que o Admin Dashboard não deveria mostrar isso?

O Admin Dashboard virou um monitor de publicação

A primeira versão do Admin Dashboard dependia do status local do Git.

Isso fazia sentido quando a aplicação trabalhava diretamente sobre um repositório local.

Mas se o conteúdo está sendo criado através do GitHub a partir de um ambiente de Preview ou Production, um Git local deixa de ser tão útil.

Não existe necessariamente uma working tree local relevante.

Então o dashboard passou a consumir informações dos commits do GitHub.

O fluxo agora é muito mais próximo do funcionamento real do sistema:

text
Content Editor

GitHub commit

GitHub history

Admin Dashboard

Em vez de perguntar:

"Existem arquivos locais modificados?"

O dashboard pode perguntar:

"O que o sistema de publicação realmente commitou?"

Essa é uma pergunta muito mais útil em um ambiente de deployment.

Preview e Production

Quando o mecanismo de publicação começou a funcionar, surgiu outro problema.

Eu não queria testar tudo diretamente na master.

Isso significaria que cada artigo de teste, tradução ou operação de salvamento poderia modificar a fonte de conteúdo da produção.

Então a configuração do repositório passou a ser orientada por branch.

O Preview poderia utilizar uma branch dedicada:

text
feat/publishing-system-preview-and-production

enquanto Production continuaria apontando para:

text
master

O código da aplicação não precisa saber qual branch está sendo utilizada.

Isso fica por conta da configuração do ambiente.

Conceitualmente:

text
Preview

GITHUB_REPOSITORY_BRANCH

Preview publishing branch


Production

GITHUB_REPOSITORY_BRANCH

master

Essa separação tornou os testes muito mais seguros.

Agora posso criar um post diretamente pelo Preview e observar todo o ciclo de publicação sem tocar na produção.

Quando tudo estiver validado, a branch pode ser mergeada.

E então o Git resolveu revidar

Naturalmente, o projeto não poderia deixar tudo isso acontecer sem alguns problemas interessantes de Git.

Em determinado momento, a implementação da publicação estava gerando acidentalmente caminhos como:

text
D:/alexandre-blog/content/blog/...

em vez de:

text
content/blog/...

O Git, com toda razão, não fazia ideia do que eu queria dizer.

O repositório acabou ficando com caminhos malformados no histórico da branch remota.

Em determinado momento, o Git estava reportando algo como:

text
D:/alexandre-blog/content/blog/blog-github-post/pt.mdx

E o Windows, posteriormente, se recusava a trabalhar com esses caminhos como se fossem caminhos normais do repositório.

A lição foi simples:

Um caminho do filesystem e um caminho de repositório são abstrações diferentes.

Parece óbvio agora.

Não era tão óbvio enquanto eu estava tentando descobrir por que tudo estava quebrando.

Depois que essa distinção foi explicitada na arquitetura, o problema desapareceu.

A integração com o GitHub passou a receber apenas caminhos relativos ao repositório, enquanto os helpers locais continuam responsáveis pelos caminhos do filesystem.

Outra lição: --force-with-lease é seu amigo

Como os caminhos malformados e os commits de teste já haviam chegado à branch remota, limpar tudo exigiu reescrever o histórico.

A solução mais óbvia seria:

bash
git push --force

Mas fazer isso cegamente seria desnecessariamente arriscado.

Em vez disso, usei uma branch de backup e --force-with-lease.

Isso permitiu transformar a branch local no histórico limpo e canônico, mas ainda protegendo contra a possibilidade de sobrescrever uma branch remota que tivesse mudado inesperadamente.

E, como acabou acontecendo, a branch remota realmente mudou enquanto eu trabalhava.

O lease detectou a alteração.

O Git basicamente disse:

"Eu sei como você acha que o remoto está, mas ele mudou. Não vou sobrescrevê-lo."

E era exatamente isso que eu queria que ele dissesse.

A arquitetura hoje

O que começou como uma coleção de arquivos MDX agora possui uma pequena arquitetura de publicação ao redor.

Em alto nível:

text
                     ┌──────────────────┐
                     │  Admin Dashboard │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │  Content Editor  │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │ Admin API Routes │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │   lib/content    │
                     │                  │
                     │ Content domain   │
                     │ MDX / locales    │
                     │ serialization    │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │ lib/github/      │
                     │ content          │
                     │                  │
                     │ GitHub adapter   │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │ GitHub Repository│
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │      Vercel      │
                     │  automatic build │
                     └────────┬─────────┘


                     ┌──────────────────┐
                     │ Published Site   │
                     └──────────────────┘

Ainda é um site pessoal.

Ele só acontece de possuir agora um pequeno pipeline de publicação por trás.

Por que eu gosto dessa abordagem

Existem, obviamente, maneiras muito mais sofisticadas de construir isso.

Eu poderia introduzir um banco de dados.

Poderia usar um headless CMS.

Poderia construir um backend separado.

Poderia introduzir uma fila, um sistema de webhooks ou até um serviço dedicado de publicação.

Para uma plataforma de publicação grande, essas escolhas poderiam fazer todo sentido.

Para este projeto, provavelmente seriam complexidade desnecessária.

A parte interessante dessa arquitetura é que ela utiliza infraestrutura da qual eu já precisava.

O conteúdo já possui controle de versão.

O GitHub já fornece autenticação.

O GitHub já fornece histórico do repositório.

O GitHub já fornece uma API para manipular arquivos.

O Vercel já observa o repositório e sabe como construir a aplicação.

Então, em vez de introduzir mais um sistema para conectar todas essas peças, o projeto começou a tratá-las, cada vez mais, como partes de um único sistema.

E talvez essa tenha sido a parte mais interessante de todo o processo.

O que vem a seguir?

O ciclo de desenvolvimento que originalmente considerei "essencial" está chegando perto do fim.

Mas, naturalmente, agora que a infraestrutura existe, algumas ideias começam a aparecer.

Algumas são práticas:

  • 📊 Um contador de visualizações para os artigos.
  • 📄 Uma maneira de permitir que recrutadores baixem a seção profissional da página About como um PDF devidamente formatado.

Outras são mais ambiciosas:

  • 💬 Eventualmente permitir que visitantes se autentiquem com GitHub.
  • ❤️ Permitir que visitantes autenticados curtam artigos.
  • 💬 Permitir que visitantes comentem.

Essa última categoria mudaria novamente a arquitetura.

Visitantes somente de leitura são fáceis.

Um leitor autenticado capaz de criar dados persistentes é outra história completamente diferente.

E talvez seja justamente por isso que seria interessante construir.

De blog a playground

A parte engraçada é que o projeto ainda não é realmente um CMS.

Pelo menos, não é assim que eu o vejo.

Ele ainda é o meu site pessoal.

Os arquivos MDX continuam lá.

O conteúdo continua versionado.

O deployment continua relativamente simples.

Mas, em algum momento do caminho, o projeto se tornou um pequeno playground para explorar como arquitetura de aplicações, gerenciamento de conteúdo, autenticação, Git, APIs, CI/CD, localização e infraestrutura de deployment podem se encaixar.

E isso provavelmente foi mais valioso do que o objetivo original de simplesmente ter um lugar para escrever.

Eu comecei com:

"Quero um lugar para publicar alguns artigos."

E acabei com:

Admin Dashboard → Content Editor → GitHub → Vercel → Production

E, honestamente?

Para um humilde site pessoal, é um sistema bem divertido de ter construído por acidente.