portfolio

Architecture

Principles

  1. Content and presentation stay separate.
  2. Shared UI lives in src/components; page-specific blocks live in src/features.
  3. Logic and formatting live in src/lib, pure and unit tested.
  4. All public content passes a Zod schema before it renders.
  5. The editorial rules of the site are enforced by the schema and by tests, not by discipline.

Layers

Dependency rule

app may import anything. features may import components, content, config and lib. components never imports features or app. content never imports UI.

The case study contract

projectSchema in src/content/schema.ts is the important file. It forces every case study into the same shape:

Field Purpose
context The real problem, before any solution is mentioned.
architecture A diagram id plus alt text. Validated against the diagram registry.
decisions[] choice, alternative, rationale — as separate fields.
cicd[] How it was delivered and operated.
results[] Label and value pairs. Evidence, not adjectives.

decisions is modelled as a structured array rather than prose on purpose. Prose lets hand-waving hide; being forced to name what you chose and what you rejected and why does not. It also means a future case study cannot quietly skip the reasoning — the schema rejects it.

Diagrams

Architecture diagrams are React components emitting inline SVG, in src/components/visual/architecture-diagram.tsx, keyed by the diagramIds enum in the schema.

Inline rather than image files so the strokes and labels use currentColor and follow the theme toggle exactly. A .svg loaded through <img> cannot see the page theme; a PNG would need two copies. This costs nothing at runtime: no diagram library, no extra request, no client JavaScript.

Because the ids are an enum, a case study referencing a diagram that does not exist fails the type check rather than rendering an empty box.

Rendering and hosting

output: "export" — the whole site is prerendered to static HTML in out/ and served by GitHub Pages behind the matheuspaiva.dev custom domain. No server, no database, no runtime.

Two consequences worth remembering:

The Docker image builds the same static output and serves it with nginx, which is where the security headers do get applied.

First-party analytics

The static site sends small, non-blocking events to the Django collector configured through NEXT_PUBLIC_ANALYTICS_ENDPOINT. Production uses https://admin.matheuspaiva.dev/api/analytics/collect/; failure of that service never affects navigation or rendering.

The collector accepts only configured origins, ignores common bots, rate-limits writes and stores neither IP addresses nor raw browser identifiers. Visitor and 30-minute session tokens are created in the browser and HMACed by the server before storage. Referrers are reduced to their hostname.

Detailed events are retained for 90 days. Run python manage.py prune_analytics daily: it writes permanent daily totals before removing expired detail. The profile screen links to an opt-out flag stored on matheuspaiva.dev, so the owner’s browser does not count while normal visitors remain anonymous.

Where content comes from

Content is authored in the control panel (admin/, admin-ui/) and stored in Postgres. The build never reaches the panel or the database:

control panel  ->  Postgres  ->  pnpm content:pull  ->  src/content/content.json  ->  build

pnpm content:pull authenticates against the panel, calls /api/export/, and writes src/content/content.json. That file is committed. src/content/index.ts parses it once with contentSchema, and projects.ts, posts.ts and config/site.ts read from there.

Three properties fall out of that direction:

The one value that stays outside the panel is NEXT_PUBLIC_SITE_URL: it describes where the build is deployed, not what is being said, and it has to be known before any content is read.