Notas

Como este site é feito

12 set 2026 · 7 min·read in EN

Angular estático, sem servidor, uma CSP rígida e um gate de CI que mede bytes em vez de nota. O que está no repositório, para que serve cada peça e as duas decisões que eu desfaria.

Este site é um app Angular 22 estático. O ng build pré-renderiza cada rota em HTML, o Cloudflare Pages serve a pasta e não existe servidor em nenhum ponto do caminho. Parece a escolha sem graça, e é; o que vem a seguir é, em boa parte, o que custou mantê-la sem graça.

Pré-renderizar, depois hidratar

Cada página existe duas vezes, em / e em /pt/. O idioma vem da URL, então o botão de troca no menu é um link comum para a página irmã — sem cookie, sem redirect, sem piscar no idioma errado. As duas árvores são pré-renderizadas pelo outputMode: 'static' e depois hidratadas no navegador com event replay, para que um clique dado durante o carregamento não se perca.

Todo o texto mora em src/content, num schema tipado. Cada string visível é um L<T> = { en: T; pt: T }, e um conjunto de testes percorre a árvore de conteúdo exportada de forma genérica: todo L<string> é não vazio nos dois idiomas, todo L<string[]> tem a mesma quantidade de itens de cada lado, as datas são meses reais e não estão no futuro, os links dos projetos são HTTPS absolutos. Eram 28 quando escrevi isto, e nenhum lista campo à mão — um L<> novo em qualquer lugar do schema já nasce coberto.

Um deles é uma verificação de privacidade. A seção de experiência não cita nenhum cliente final de propósito, então o teste procura nos arquivos de conteúdo os nomes que eu removi e falha se algum voltar. É o tipo de asserção que parece paranoia até o dia em que você cola um bullet de um CV antigo.

O hero, e o que ele não pode custar

A home tem um clipe em loop atrás do hero. A primeira versão carregava esse vídeo como qualquer outro, e uma rodada de Lighthouse mobile deu nota na casa dos quarenta, porque um WebM de 846 kB estava disputando banda com as fontes e com a primeira pintura.

A correção não foi diminuir o vídeo, embora ele também tenha ganhado um encode mobile de 193 kB. A correção foi segurá-lo. O HTML pré-renderizado não contém nenhum <source>; os sources entram no cliente, e só depois de um atraso fixo e de um idle callback:

export function whenIdle(start: () => void, delay = HERO_VIDEO_DELAY_MS): void {
  if (typeof window === 'undefined') return;
  const idle = (window as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback;
  window.setTimeout(() => (idle ? idle(start, { timeout: 600 }) : start()), delay);
}

O atraso vem primeiro de propósito. Sozinho, o requestIdleCallback dispara em poucas centenas de milissegundos numa conexão rápida, que é exatamente a janela que isso deveria deixar livre. Mil e duzentos milissegundos de nada, depois idle, depois o clipe. Quem pediu menos movimento, ligou o saveData ou está em 2G não recebe vídeo nenhum; uma viewport estreita recebe o encode leve.

O objeto em wireframe ao lado do hero segue a mesma regra. Three.js são uns 150 kB na rede para um enfeite, então a página renderiza primeiro um SVG estático e o objeto de verdade carrega depois, atrás do mesmo whenIdle. Quando está de pé, o loop de renderização pausa sempre que o objeto sai da viewport ou a aba fica oculta — não há motivo para gastar frame com algo que ninguém vê.

Uma CSP que fixa o Angular por hash

O site sai com uma Content Security Policy rígida: nada de 'unsafe-inline' para script, cada trecho inline liberado pelo seu SHA-256. A pré-renderização do Angular emite exatamente três: o handler onload do link da folha de estilo assíncrona, o contrato do event replay e a chamada de bootstrap que lista quais eventos o replay vai capturar.

Esse último é a armadilha. window.__jsaction_bootstrap(document.body,"ng",["click"],[]) é a versão da página do CV. A home tem cards que acendem sob o cursor, então a versão dela diz ["click","pointermove"]. Bytes diferentes, hash diferente. O array muda toda vez que uma página passa a reproduzir um tipo de evento que antes não reproduzia, e ninguém avisa — o navegador bloqueia o script, o replay para de funcionar em silêncio e o único rastro é uma mensagem no console.

Um hash fixa um comportamento, não um arquivo. Ele muda quando o template muda, e o template não vai avisar.

Então o CI percorre cada página pré-renderizada depois do build, calcula o hash de cada script e handler inline que encontra e falha quando algum não está no _headers, imprimindo o 'sha256-…' exato para colar. Também aponta hash que nenhuma página emite mais. Menos de cem linhas de Node, e a falha saiu do console do navegador para o pull request.

Orçamento em bytes, não em nota

O Lighthouse roda no CI contra o build que acabou de sair, nunca contra o site no ar. O gate não é a nota de performance. Esse número acompanha a velocidade da máquina — o mesmo build da home pontuou entre 41 e 50 no mobile em cinco rodadas, e entre 72 e 96 no desktop. Um piso assim tão baixo pega um colapso, não uma regressão.

O que é determinístico é o tamanho transferido. Bytes estáticos são idênticos de uma rodada para outra, então cada URL tem um orçamento de total, script, imagem, fonte e mídia, com uma margem fina sobre o valor medido. O Angular somar 30 kB ao bundle principal derruba o build; um runner lento, não.

Um desses números ficou errado por um tempo. O orçamento de mídia do desktop foi ajustado em rodadas nas quais o clipe do hero não tinha chegado dentro da janela da auditoria, e depois ficou vermelho num build cuja mídia não tinha mudado nada — um runner rápido simplesmente baixou o arquivo inteiro. Hoje o orçamento cobre o clipe todo, e o bloco de comentário no config registra o porquê.

O CV é uma rota

Existe um CV, em dois idiomas, e ele é uma página: /cv e /pt/cv. Os PDFs que as pessoas baixam são impressos a partir dessas rotas por um Chromium headless com --print-to-pdf, diagramados em duas páginas por CSS de impressão, e commitados junto com a mudança de conteúdo que os gerou. Não existe uma segunda fonte de verdade para desalinhar.

Cloudflare Pages, com a versão cravada

O deploy é a integração do Cloudflare com o GitHub: push na main, o build roda, a pasta vai para o ar. A única coisa que vale anotar é o NODE_VERSION. Um 24 seco resolveu para 24.13.1 do lado do Cloudflare, versão que o Angular 22.1 recusa. A versão exata está cravada no ambiente do Pages, no .node-version e no .nvmrc, e é o mesmo número nos três.

O 404 é de verdade. O build pré-renderiza uma página de não encontrado, um passo de pós-build a move para 404.html, e o Pages serve esse arquivo com status 404 para qualquer caminho que não tenha arquivo — o que também desliga o fallback de SPA que respondia toda URL desconhecida com a home e um 200.

O que eu faria diferente

Duas coisas. Escreveria o verificador de CSP antes da CSP, e não depois do primeiro script que ela bloqueou em silêncio. E começaria pelos orçamentos em bytes, pulando os pisos de nota por completo; eles nunca pegaram nada que os orçamentos não tivessem pego antes, e um piso baixo o bastante para não oscilar num runner lento é baixo demais para significar alguma coisa.