235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
# nak0x.dev
|
|
|
|
A monospace, terminal-shaped portfolio in Nuxt 4, with a blog whose posts live
|
|
in a separate git repo as plain markdown. Push a `.md`, a webhook fires, the
|
|
post is live. No rebuild, no CMS, no database.
|
|
|
|
```
|
|
browser ──► nuxt (nitro, SSR)
|
|
│
|
|
├─ / ──► site.json ─┐
|
|
├─ /blog/* ──► posts/*.md ─┼──► gitea api ──► content repo
|
|
└─ /dash ──► edits both ─┘ ▲
|
|
│
|
|
push ────────────────────────────┘
|
|
└─ webhook ──► /api/webhook/content (busts cache)
|
|
```
|
|
|
|
Two things live in the content repo: `posts/*.md` (the blog) and `site.json`
|
|
(the portfolio — bio, skills, links, projects). `/dash` edits both and commits
|
|
through the git API. Everything else is code.
|
|
|
|
## quick start
|
|
|
|
```sh
|
|
npm install
|
|
cp .env.example .env # then edit it
|
|
npm run dev # http://localhost:3000
|
|
```
|
|
|
|
Out of the box `.env.example` points at Gitea. To write offline against the
|
|
`content/posts/` folder in this repo instead:
|
|
|
|
```sh
|
|
CONTENT_PROVIDER=local npm run dev
|
|
```
|
|
|
|
## where your content goes
|
|
|
|
Two repos, on purpose: the site is code, the blog is prose, and they have very
|
|
different commit rhythms.
|
|
|
|
```
|
|
Journal/ # the content repo (git.nak0x.dev/Nak0x/Journal)
|
|
├── site.json # portfolio content — written by /dash
|
|
└── posts/
|
|
├── 2026-09-08-hello.md
|
|
└── rust-on-old-thinkpads/
|
|
├── index.md
|
|
└── bench.png #  resolves against the post's dir
|
|
```
|
|
|
|
`site.json` is optional. Without it the site uses the seed compiled into
|
|
[`shared/site.ts`](./shared/site.ts); the first save from `/dash` creates the
|
|
file. A malformed or partial `site.json` degrades field by field back to that
|
|
seed rather than taking the site down.
|
|
|
|
Frontmatter — everything is optional except, in practice, `title`:
|
|
|
|
```yaml
|
|
---
|
|
title: how this blog works
|
|
description: one line for the list view and the og tags
|
|
date: 2026-09-08 # or just name the file 2026-09-08-*.md
|
|
updated: 2026-09-09
|
|
tags: [meta, nuxt]
|
|
slug: how-this-blog-works # defaults to the filename, date prefix stripped
|
|
draft: true # hidden in production, visible in dev
|
|
---
|
|
```
|
|
|
|
`content/posts/2026-09-08-hello.md` in this repo is a working example — copy it
|
|
into the content repo as your first post.
|
|
|
|
## wiring the webhook
|
|
|
|
**Gitea** — repo → Settings → Webhooks → *Add Webhook* → *Gitea*:
|
|
|
|
| field | value |
|
|
| ------------- | ------------------------------------------ |
|
|
| Target URL | `https://nak0x.dev/api/webhook/content` |
|
|
| HTTP Method | `POST` |
|
|
| Content Type | `application/json` |
|
|
| Secret | same value as `CONTENT_WEBHOOK_SECRET` |
|
|
| Trigger On | Push Events |
|
|
| Branch filter | `main` |
|
|
|
|
**GitHub** — same URL and secret, content type `application/json`, "Just the
|
|
push event". The handler accepts both `X-Gitea-Signature` and
|
|
`X-Hub-Signature-256`.
|
|
|
|
Generate the secret with `openssl rand -hex 32`. Requests without a valid HMAC
|
|
get a 401, so the endpoint is safe to leave public.
|
|
|
|
The webhook is an optimisation, not a dependency: `CONTENT_TTL` (default 600s)
|
|
expires the cache anyway, and a stale copy is served while the refresh runs —
|
|
so a Gitea outage degrades to "slightly old posts", never to a 502.
|
|
|
|
## deploying on Coolify
|
|
|
|
1. **New Resource → Application → Dockerfile** (or *Public/Private Repository*
|
|
with Build Pack = `Dockerfile`). Coolify picks up the `Dockerfile` at the
|
|
repo root.
|
|
2. **Port**: `3000`. The image already sets `HOST=0.0.0.0`.
|
|
3. **Health check path**: `/healthz` (the image also has a `HEALTHCHECK`).
|
|
4. **Environment variables** — paste from `.env.example` and fill in:
|
|
`CONTENT_PROVIDER`, `CONTENT_REPO`, `CONTENT_BRANCH`, `CONTENT_DIR`,
|
|
`GITEA_URL`, `CONTENT_WEBHOOK_SECRET`, `NUXT_PUBLIC_SITE_URL`, plus
|
|
`DASH_PASSWORD` and `CONTENT_TOKEN` if you want to edit from `/dash`.
|
|
5. **Build variable** — `NUXT_PUBLIC_SITE_URL` is inlined into the client
|
|
bundle, so it also has to exist at build time. In Coolify, tick *"Build
|
|
Variable"* on it, or set the build arg `SITE_URL`.
|
|
6. **Domain**: `https://nak0x.dev`. Coolify terminates TLS in front.
|
|
|
|
Redeploy on push to *this* repo is the usual Coolify webhook. Posts do not need
|
|
a redeploy at all — that is the whole point.
|
|
|
|
Locally, `docker compose up --build` runs the same image.
|
|
|
|
## environment
|
|
|
|
| variable | default | what it does |
|
|
| ------------------------ | ------------------------ | --------------------------------------------------- |
|
|
| `NUXT_PUBLIC_SITE_URL` | `https://nak0x.dev` | canonical URL for rss, sitemap, og tags |
|
|
| `CONTENT_PROVIDER` | `gitea` | `gitea` \| `github` \| `local` |
|
|
| `CONTENT_REPO` | `Nak0x/Journal` | `owner/repo` of the content repo |
|
|
| `CONTENT_BRANCH` | `main` | branch to read |
|
|
| `CONTENT_DIR` | `posts` | sub-directory holding the markdown (`''` = root) |
|
|
| `GITEA_URL` | `https://git.nak0x.dev` | Gitea base URL (ignored for github) |
|
|
| `CONTENT_TOKEN` | — | PAT, only for private repos or GitHub rate limits |
|
|
| `CONTENT_TTL` | `600` | seconds before a cached post goes stale |
|
|
| `CONTENT_WEBHOOK_SECRET` | — | HMAC secret; without it the webhook returns 503 |
|
|
| `CONTENT_LOCAL_DIR` | `content` | repo root used when `CONTENT_PROVIDER=local` |
|
|
| `CONTENT_SITE_FILE` | `site.json` | portfolio data, relative to the repo root |
|
|
| `PROJECTS_GITHUB_USER` | `nak0x` | github account to list repos from (`''` disables) |
|
|
| `PROJECTS_GITEA_USER` | `Nak0x` | gitea account to list repos from (`''` disables) |
|
|
| `PROJECTS_GITEA_URL` | `GITEA_URL` | gitea instance to read the repos from |
|
|
| `PROJECTS_GITHUB_TOKEN` | — | PAT; lifts github's 60 req/h anonymous limit |
|
|
| `PROJECTS_GITEA_TOKEN` | `CONTENT_TOKEN` | PAT for the gitea instance |
|
|
| `PROJECTS_EXCLUDE` | — | repos to drop, comma-separated `name` or `owner/name` |
|
|
| `PROJECTS_INCLUDE_FORKS` | `false` | `true` lists forks as projects too |
|
|
| `PROJECTS_TTL` | `900` | seconds before the repo list is refetched |
|
|
| `DASH_PASSWORD` | — | unset disables `/dash` entirely |
|
|
| `DASH_SESSION_SECRET` | derived from password | HMAC key for the session cookie |
|
|
| `DASH_SESSION_HOURS` | `168` | how long a login lasts |
|
|
| `SHOW_DRAFTS` | `false` | `true` renders `draft: true` posts in production |
|
|
|
|
## /dash — editing without a terminal
|
|
|
|
`/dash` is a small single-user CMS for this site. It is disabled until
|
|
`DASH_PASSWORD` is set, and it can only *write* once `CONTENT_TOKEN` holds a
|
|
token with write access to the content repo (Gitea: scope `write:repository`).
|
|
Without the token it still works, read-only.
|
|
|
|
```sh
|
|
DASH_PASSWORD=$(openssl rand -hex 24) # login
|
|
CONTENT_TOKEN=… # gitea/github PAT, write scope
|
|
```
|
|
|
|
| page | what it does |
|
|
| ------------------- | ------------------------------------------------------------------ |
|
|
| `/dash` | repo status, post list, cache refresh, create/delete posts |
|
|
| `/dash/posts/:slug` | markdown editor with live preview; `new` creates a post |
|
|
| `/dash/site` | bio, skills, links and the whole project list |
|
|
|
|
Every save is one commit through the git API — no separate deploy, no rebuild,
|
|
and the history is the same one you get from pushing by hand. The editor sends
|
|
the sha it loaded with each save, so if you also edited the file from nvim in
|
|
the meantime you get a 409 instead of a silent overwrite.
|
|
|
|
The preview pane posts the body to `/api/dash/preview` and renders the HTML the
|
|
published page would show, Shiki highlighting included — the client never gets a
|
|
markdown parser.
|
|
|
|
**Auth.** A password check sets an HttpOnly, SameSite=Lax, HMAC-signed cookie
|
|
(`Secure` behind an https proxy), valid for `DASH_SESSION_HOURS`. The signing
|
|
key defaults to a value derived from the password, so changing the password logs
|
|
every session out. Failed logins are rate-limited per IP (8 per 10 minutes).
|
|
`/dash` is `Disallow`ed in `robots.txt` and sends `noindex`.
|
|
|
|
Every `/api/dash/*` route checks the session server-side; the route middleware
|
|
only spares you a page that would 401 on load.
|
|
|
|
## where the project list comes from
|
|
|
|
`/projects` is not a list anybody maintains: it is every **public repository**
|
|
of `PROJECTS_GITHUB_USER` and `PROJECTS_GITEA_USER`, read from both forges on
|
|
every cache miss and merged. Forks are skipped unless
|
|
`PROJECTS_INCLUDE_FORKS=true`, and a repo on both forges is shown once (github
|
|
wins). Each repo maps to a card on its own:
|
|
|
|
| card | comes from |
|
|
| ------------ | ---------------------------------------------------------- |
|
|
| tagline | the repo description |
|
|
| stack | the primary language, then the repo topics |
|
|
| year | the year the repo was created |
|
|
| status | `archived` if archived, `active` if pushed in the last 120 days, else `shipped` |
|
|
|
|
The `projects` block in `site.json` is an **overlay** on top of that, not the
|
|
list itself. An entry whose `href` (preferred) or `name` matches a repository
|
|
replaces that repo's fields wherever it actually says something — a nicer name,
|
|
hand-written bullets, `featured: true` for the home page, `hidden: true` to drop
|
|
it from the site. An entry that matches no repository is still shown, so a
|
|
project with no repo behind it does not disappear. All of it is editable from
|
|
`/dash`.
|
|
|
|
Both forges are independent: if github rate-limits you, the gitea repos still
|
|
render and `/projects` says one source is missing. If *both* fail, the overlay
|
|
entries are all that is left — which is exactly the old hand-written list.
|
|
|
|
## editing the portfolio in code instead
|
|
|
|
[`shared/site.ts`](./shared/site.ts) holds the seed and the types. Edit it when
|
|
you want a change tracked in *this* repo rather than the content one — but note
|
|
that a `site.json` in the content repo overrides it.
|
|
|
|
## routes
|
|
|
|
| route | what |
|
|
| ------------------------ | ------------------------------------------- |
|
|
| `/` | hero, about, skills, featured work, latest posts |
|
|
| `/projects` | every public repo, github + gitea |
|
|
| `/blog`, `/blog?tag=x` | index, filterable by tag |
|
|
| `/blog/:slug` | a post, with TOC and prev/next |
|
|
| `/dash`, `/dash/*` | the editor (password-protected, noindex) |
|
|
| `/rss.xml` | feed |
|
|
| `/sitemap.xml` | sitemap |
|
|
| `/healthz` | liveness probe |
|
|
| `/api/webhook/content` | push webhook (POST, HMAC-signed) |
|
|
|
|
## stack
|
|
|
|
Nuxt 4 · Nitro (node-server) · markdown-it + Shiki · gray-matter · hand-written
|
|
CSS. No UI framework, no client-side markdown, no database, ~zero JavaScript on
|
|
the blog pages beyond hydration.
|