A arquitetura deste blog: como o formigadev é construído

A arquitetura deste blog

O primeiro post já contou por que esse site existe. Esse aqui é sobre como ele é montado por dentro: o que é o Analog.js, por que cada peça da stack está aqui, como um post nasce até virar página no ar, e o que mudou de verdade quando fui atrás de SEO e performance de verdade (com número, não achismo).

O que é o Analog.js

Se você nunca ouviu falar: Analog.js é um meta-framework pra Angular. "Meta" porque ele não substitui o Angular, ele organiza um projeto Angular inteiro em volta de convenções prontas, do jeito que o Next.js faz com React ou o SvelteKit faz com Svelte.

Na prática, ele resolve três coisas que, sem ele, eu teria que montar na mão:

  • Roteamento por arquivo. Não existe uma lista de rotas em lugar nenhum do código. O arquivo src/app/pages/blog/[slug].page.ts já significa a rota /blog/:slug. O nome e a posição do arquivo dentro de pages/ definem a URL.
  • Conteúdo em Markdown. Analog.js lê arquivos .md de uma pasta de conteúdo e entrega isso pronto pra um componente Angular consumir, com front-matter (aquele bloco --- no topo do arquivo) virando um objeto tipado.
  • Build com Vite. Todo o build (dev server, bundle de produção, pré-renderização de página) roda em cima do Vite, que é bem mais rápido que o build padrão do Angular CLI pra esse tamanho de projeto.

Por baixo, continua sendo um app Angular normal: componentes standalone, Signals, os mesmos conceitos de sempre. O Analog.js só tira o trabalho de configurar o resto em volta.

Um exemplo real: é assim que a página de um post pega o conteúdo do arquivo Markdown certo, só perguntando pelo parâmetro da rota:

// src/app/pages/blog/[slug].page.ts
export const routeMeta: RouteMeta = {};

export default class BlogPost {
  readonly post$ = injectContent<PostAttributes>({
    param: 'slug',
    subdirectory: 'posts',
  });
}

Sem routeMeta explícito, sem registrar rota em nenhum outro arquivo. O nome [slug].page.ts já é a definição da rota.

A árvore de diretórios

src/
├── app/
│   ├── components/       # search, comments, share-buttons, dark-mode-toggle...
│   ├── pages/             # roteamento por arquivo (@analogjs/router)
│   │   ├── blog/
│   │   │   ├── [slug].page.ts
│   │   │   └── index.page.ts
│   │   ├── tags/[tag].page.ts
│   │   ├── index.page.ts
│   │   └── sobre.page.ts
│   ├── pipes/
│   ├── app.ts             # shell: nav, footer, JSON-LD do site
│   └── seo.service.ts     # meta tags, hreflang, canonical, JSON-LD
├── content/
│   ├── posts/*.md          # posts em português
│   └── en/posts/*.md       # mesma slug, versão em inglês
└── server/routes/api/      # rotas de servidor, se precisar

scripts/
├── generate-search-index.mjs   # roda no prebuild
├── generate-seo-files.mjs      # sitemap, robots, rss
├── generate-og-card.mjs        # card de compartilhamento por post
└── upload-image.mjs            # sobe imagem pro Firebase Storage

content/ é uma árvore paralela: content/posts/x.md e content/en/posts/x.md compartilham o mesmo slug e são resolvidos pelo locale em runtime, não por rota duplicada.

A stack, peça por peça

Diagrama da stack: framework e build, processamento de conteúdo, peças client-side sob demanda e infraestrutura

  • Angular 22 + Analog.js: base do site, como já expliquei acima.
  • marked + marked-shiki + marked-gfm-heading-id: transformam o Markdown em HTML, com highlight de código de verdade (não um <pre> sem cor) e um id automático em cada heading, pra dar pra linkar uma seção específica de um post.
  • flexsearch: motor de busca que roda inteiro no navegador do leitor. Sem back-end de busca, sem chamada de rede a cada letra digitada.
  • giscus: comentários usando GitHub Discussions como banco de dados. Sem tabela de comentário pra manter, sem endpoint de moderação.
  • Firebase Hosting: serve os arquivos estáticos atrás de CDN.
  • Firebase Storage: guarda as imagens que não são branding do site (diagrama de post, card de compartilhamento), pra não deixar o repositório pesado com binário.

Cada peça entrou porque resolve uma coisa específica, não porque "é o que se usa". O critério foi sempre: dá pra fazer isso sem servidor rodando o tempo todo?

Como um post nasce

  1. Escrevo o post em português (src/content/posts/.md), com front-matter (title, description, date, tags).
  2. Escrevo a versão em inglês do zero (src/content/en/posts/.md), mesmo slug. Não é tradução linha a linha, é o mesmo assunto pensado pra quem lê em inglês.
  3. Se o post precisa de diagrama, gero o SVG e subo pro Firebase Storage com upload-image.mjs.
  4. Gero o card de compartilhamento (a imagem que aparece quando alguém manda o link no WhatsApp): um script (generate-og-card.mjs) monta um HTML com o título de verdade sobreposto no mesmo fundo da capa da página, um navegador headless tira um screenshot 1200x630, e isso também sobe pro Storage.
  5. Um script de validação confere se os dois arquivos (pt e en) existem e têm o front-matter completo antes de eu seguir.
  6. npm run build gera o HTML de cada rota já pronto (pré-renderizado).
  7. Deploy pro Firebase Hosting.

Nenhum desses passos precisa de um serviço externo de CMS. Publicar é escrever arquivo, rodar script, subir.

SEO: o que entra em cada página

Todo esse trabalho de SEO não é genérico, é o que o SeoService (src/app/seo.service.ts) monta pra cada rota, toda vez que a página muda:

  • Título e description próprios por página, não um texto fixo repetido em todo lugar.
  • Canonical e hreflang (pt-BR, en, x-default) apontando pra versão certa em cada idioma, pro Google não achar que são páginas duplicadas.
  • Open Graph e Twitter Card: título, descrição, imagem. Em posts, a imagem é o card gerado no passo 4 acima (JPEG com o título real), não um SVG genérico. SVG é ruim pra isso porque boa parte dos leitores de link preview (WhatsApp, por exemplo) não renderiza esse formato.
  • og:image:alt: descrição da imagem pra quem recebe o link e usa leitor de tela.
  • JSON-LD: dado estruturado (BlogPosting, Person, WebSite) que ajuda buscador a entender quem escreveu, quando publicou, do que se trata.
  • Sitemap e RSS gerados automaticamente no prebuild, a partir da lista real de posts, sem manutenção manual.

Tudo isso já nasce pronto no HTML porque o site é pré-renderizado (mais sobre isso na próxima seção). O Google não precisa executar JavaScript pra ver o conteúdo.

Essa chamada é o que roda toda vez que a página de um post é montada:

this.seo.update({
  title: post.attributes.title,
  description: post.attributes.description,
  path: `/blog/${post.attributes.slug}`,
  image: ogImageUrl(post.attributes.slug),
  imageAlt: post.attributes.title,
  type: 'article',
  publishedTime: isoDate,
});

Um método só, chamado com os dados do post, e o SeoService decide sozinho título, canonical, hreflang, Open Graph e JSON-LD a partir disso.

Performance: o que mudou quando fui medir de verdade

Documentar arquitetura é fácil de fazer parecer bonito no papel. O teste de verdade é rodar o Lighthouse contra o site publicado e ver o que aparece. Rodei, e apareceu coisa real:

  • CSS bloqueando o primeiro render. O CSS global (uns 3KB) carregava como <link> externo, obrigando o navegador a esperar esse download antes de pintar a página. Como é o mesmo arquivo em toda rota, um passo no build agora cola o CSS direto dentro do <head> de cada HTML pré-renderizado. Menos uma requisição no caminho crítico.
  • Foto de autor gigante pra um avatar pequeno. A imagem tinha 449x484 e 84KB, exibida a 56px de largura. Cortei pra 280x280 e recomprimi: 22KB. Uma imagem 4x menor que ninguém percebe diferença, porque o tamanho de exibição sempre foi pequeno. Resultado em produção: Performance 99, LCP (tempo até o maior elemento visível aparecer) em 2.1s, Best Practices 96.

Exemplo de fix pequeno com efeito real: um passo a mais no build agora troca o <link> de CSS por um <style> inline em cada página gerada, porque o arquivo é o mesmo (3KB) em toda rota e não vale a pena pagar uma requisição extra pra baixá-lo:

// scripts/inline-critical-css.mjs (roda depois do vite build)
const css = readFileSync(cssPath, 'utf-8');
const html = readFileSync(pagePath, 'utf-8');
writeFileSync(pagePath, html.replace(linkTag, `<style>${css}</style>`));

Acessibilidade: o que a auditoria achou

No dia 18 de julho de 2026, no Google I/O Extended João Pessoa, evento do GDG João Pessoa, assisti a palestra de Angelo Dias, "Acessibilidade Web escalável: como a IA Generativa está salvando a WCAG na nuvem", sobre usar IA generativa, Gemini e Shift-Left Testing pra apoiar acessibilidade desde cedo no desenvolvimento. Foi o empurrão que faltava pra parar de teorizar e rodar uma auditoria de verdade neste post.

Essa parte custou mais barato do que eu esperava, e é a que mais teria impacto real num leitor usando leitor de tela ou navegando sem mouse. As regras usadas como referência vêm do WCAG (Web Content Accessibility Guidelines), o padrão internacional que define o que conta como "acessível" na prática, com critérios objetivos e mensuráveis, como o de contraste mínimo do primeiro item abaixo:

  • Contraste de cor insuficiente. As tags de cada post (#angular, #ssr...) tinham contraste de 3.45:1 no tema escuro, abaixo do mínimo de 4.5:1 exigido pra texto pequeno. Causa real: um opacity: 0.75 no elemento pai esmaecia a tag junto com a data ao lado, quando só a data devia ficar mais apagada. Troquei por color no elemento certo, sem mexer na tag.
  • Imagem sem alt. A imagem de capa do post não tinha atributo alt. Como ela é puramente decorativa (o título real já existe como texto separado, sobreposto por CSS), o certo aqui não é escrever uma descrição, é marcar alt="" explicitamente: isso diz pro leitor de tela "pule essa imagem, ela não carrega informação".
  • Página sem <main>. O <router-outlet> no shell do app (src/app/app.ts) não estava dentro de nenhum marco <main>. Sem isso, quem navega por teclado ou leitor de tela perde o atalho de pular direto pro conteúdo, tendo que passar pelo menu toda vez.

Os dois últimos, lado a lado (antes/depois):

<!-- antes -->
<img ngSrc="/images/post-cover-bg.svg" width="1200" height="630" />
<router-outlet />

<!-- depois -->
<img ngSrc="/images/post-cover-bg.svg" alt="" width="1200" height="630" />
<main>
  <router-outlet />
</main>

Resultado em produção: Acessibilidade 100.

O placar final

Juntando as três seções acima, medido contra a página deste post em produção: Performance 99, Acessibilidade 100, Best Practices 96, SEO 100. Nenhum desses números veio de otimização prematura, vieram de rodar a ferramenta e consertar o que ela realmente achou.

Resultado do PageSpeed Insights para este post: Performance 99, Acessibilidade 100, Best Practices 96, SEO 100

O que ficou de fora, de propósito

  • Nenhum CMS. Post é arquivo Markdown no repositório, versionado com o resto do código.
  • Nenhum backend próprio. Comentário vive no GitHub Discussions via giscus, busca roda no navegador do leitor.
  • Nenhuma imagem de capa por post dentro da página. Todo post usa o mesmo fundo compartilhado, com o título sobreposto via CSS. Menos decisão de design por post, mais consistência visual entre eles.

Cada uma dessas é uma peça a menos rodando em produção, o que no fim é o motivo de esse blog não ter exigido praticamente nenhuma manutenção desde que foi ao ar.

Dúvidas ou sugestão? Deixa aqui em baixo, a ideia é crescer junto com a comunidade.

Henrique Guedes FormigaHenrique Guedes FormigaSênior Angular Developer