feat: new-version

This commit is contained in:
nak0x 2026-09-08 19:54:06 +02:00
commit 15d5112ded
77 changed files with 16236 additions and 0 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
.git
.github
node_modules
.nuxt
.output
.data
dist
*.log
.env
.env.*
!.env.example
.DS_Store
README.md
docker-compose.yml

63
.env.example Normal file
View File

@ -0,0 +1,63 @@
# ---- public ----------------------------------------------------------------
# used for canonical urls, rss and og tags. must also be passed at *build* time
# (Dockerfile ARG SITE_URL) because it lands in the client bundle.
NUXT_PUBLIC_SITE_URL=https://nak0x.dev
# ---- where the blog lives --------------------------------------------------
# gitea | github | local
CONTENT_PROVIDER=gitea
CONTENT_REPO=Nak0x/Journal
CONTENT_BRANCH=main
# sub-directory inside the repo holding the .md files ('' = repo root)
CONTENT_DIR=posts
# only read when CONTENT_PROVIDER=gitea
GITEA_URL=https://git.nak0x.dev
# personal access token — only needed for a private repo (gitea: scope read:repository)
CONTENT_TOKEN=
# seconds before a cached post is considered stale (it is still served while
# refreshing). the webhook makes this a safety net, not the main path.
CONTENT_TTL=600
# only read when CONTENT_PROVIDER=local. acts as the repo root: posts live in
# <CONTENT_LOCAL_DIR>/<CONTENT_DIR>, site.json at <CONTENT_LOCAL_DIR>/site.json
CONTENT_LOCAL_DIR=content
# portfolio content (bio, skills, links, projects), relative to the repo root.
# absent from the repo => the seed in shared/site.ts is used until /dash saves.
CONTENT_SITE_FILE=site.json
# ---- projects --------------------------------------------------------------
# /projects is every public repo of these accounts — there is no hand-written
# list. leave one empty to switch that forge off.
PROJECTS_GITHUB_USER=nak0x
PROJECTS_GITEA_USER=Nak0x
# defaults to GITEA_URL above
PROJECTS_GITEA_URL=
# optional PATs. github allows 60 anonymous requests/hour per IP; a token
# raises that to 5000. the gitea one falls back to CONTENT_TOKEN.
PROJECTS_GITHUB_TOKEN=
PROJECTS_GITEA_TOKEN=
# repos to drop, comma-separated; matches "name" or "owner/name". for anything
# more than a handful, tick "hide" on the project in /dash instead.
PROJECTS_EXCLUDE=
# forks belong to somebody else
PROJECTS_INCLUDE_FORKS=false
# seconds before the repo list is refetched
PROJECTS_TTL=900
# ---- webhook ---------------------------------------------------------------
# shared secret between this app and gitea/github. generate with:
# openssl rand -hex 32
CONTENT_WEBHOOK_SECRET=
# ---- /dash -----------------------------------------------------------------
# unset => the dashboard is disabled entirely (login always 503s).
# writing from /dash also needs CONTENT_TOKEN above, with repo write scope.
DASH_PASSWORD=
# optional; defaults to a key derived from DASH_PASSWORD, so changing the
# password already invalidates every open session.
DASH_SESSION_SECRET=
# how long a login lasts, in hours
DASH_SESSION_HOURS=168
# ---- misc ------------------------------------------------------------------
# render posts with `draft: true` in production too
SHOW_DRAFTS=false

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules
.nuxt
.output
.data
dist
.env
.env.*
!.env.example
*.log
.DS_Store

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1
# ---- build -----------------------------------------------------------------
FROM node:24-alpine AS build
WORKDIR /app
ENV NODE_ENV=development
# install deps first so the layer caches on lockfile changes only.
# --ignore-scripts skips `nuxt prepare`, which needs the source we have not
# copied yet; `nuxt build` runs it anyway.
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY . .
# baked into the client bundle, so it has to be present at build time.
# override with --build-arg SITE_URL=... (coolify: Build Variables)
ARG SITE_URL=https://nak0x.dev
ENV NUXT_PUBLIC_SITE_URL=$SITE_URL
RUN NODE_ENV=production npm run build
# ---- runtime ---------------------------------------------------------------
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000 \
NITRO_PORT=3000
# nitro's output is self-contained: no node_modules, no package.json needed
COPY --from=build --chown=node:node /app/.output ./.output
# only read when CONTENT_PROVIDER=local; a couple of KB, keeps that path usable
COPY --from=build --chown=node:node /app/content ./content
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1
CMD ["node", ".output/server/index.mjs"]

234
README.md Normal file
View File

@ -0,0 +1,234 @@
# 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 # ![](./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.

22
app/app.vue Normal file
View File

@ -0,0 +1,22 @@
<script setup lang="ts">
const { public: pub } = useRuntimeConfig()
const { site } = useSiteData()
useHead({
titleTemplate: (chunk) => (chunk ? `${chunk} — ${site.value.domain}` : site.value.domain),
})
useSeoMeta({
description: () => site.value.description,
ogType: 'website',
ogSiteName: () => site.value.domain,
ogUrl: pub.siteUrl,
twitterCard: 'summary',
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

496
app/assets/css/main.css Normal file
View File

@ -0,0 +1,496 @@
/* ---------------------------------------------------------------------------
nak0x.dev — a terminal-shaped sheet of paper.
Everything is monospace, everything sits on a 1px ink rule.
--------------------------------------------------------------------------- */
:root,
:root[data-theme='light'] {
--paper: #f6f3ec;
--paper-soft: #efece4;
--ink: #0a0a0a;
--ink-mute: #4a4a4a;
--ink-faint: #8a8a8a;
--rule: #0a0a0a;
--accent: #D97629;
}
:root[data-theme='dark'] {
--paper: #0e0e10;
--paper-soft: #16161a;
--ink: #ece9e0;
--ink-mute: #9a9a9a;
--ink-faint: #6a6a6a;
--rule: #ece9e0;
--accent: #D97629;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--paper: #0e0e10;
--paper-soft: #16161a;
--ink: #ece9e0;
--ink-mute: #9a9a9a;
--ink-faint: #6a6a6a;
--rule: #ece9e0;
--accent: #e56fd8;
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--paper);
color: var(--ink);
}
html {
font-family: 'JetBrains Mono', ui-monospace, 'IBM Plex Mono', Menlo, Monaco,
'Liberation Mono', 'DejaVu Sans Mono', Consolas, monospace;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
}
::selection {
background: var(--ink);
color: var(--paper);
}
h1,
h2,
h3,
h4 {
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.05rem; text-transform: lowercase; }
h3 { font-size: 0.95rem; text-transform: lowercase; color: var(--ink-mute); }
p { margin: 0; }
a {
color: var(--ink);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:hover {
background: var(--accent);
color: var(--paper);
text-decoration: none;
}
hr {
border: 0;
border-top: 1px solid var(--rule);
margin: 1rem 0;
}
pre { margin: 0; font-family: inherit; }
/* --- layout --------------------------------------------------------------- */
.paper {
max-width: 1080px;
margin: 0 auto;
padding: 2.5rem 1.25rem 6rem;
position: relative;
z-index: 1;
}
.stack > * + * { margin-top: 1.25rem; }
.stack-sm > * + * { margin-top: 0.5rem; }
.row {
display: flex;
gap: 1rem;
align-items: baseline;
flex-wrap: wrap;
}
.column { display: grid; gap: 1.25rem; }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 700px) {
.grid-2 { grid-template-columns: 1fr; }
}
/* --- the frame: a 1px box with `+` corners, like a TUI widget -------------- */
.frame {
position: relative;
border: 1px solid var(--ink);
background: var(--paper);
padding: 1rem 1.25rem;
}
.frame::before,
.frame::after {
content: '+';
position: absolute;
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
background: var(--paper);
color: var(--ink);
font-size: 0.95rem;
z-index: 2;
pointer-events: none;
}
.frame::before { top: -6px; left: -6px; }
.frame::after { bottom: -6px; right: -6px; }
.frame-tag {
position: absolute;
top: -0.6rem;
left: 0.75rem;
background: var(--paper);
padding: 0 0.4rem;
font-size: 0.75rem;
text-transform: lowercase;
letter-spacing: 0.04em;
color: var(--ink-mute);
z-index: 2;
}
.accent-on-hover:hover {
/* the box is often an <a>, so undo the global link-hover fill */
background: var(--paper);
border-color: var(--accent);
}
.accent-on-hover:hover::before,
.accent-on-hover:hover::after {
color: var(--accent);
}
/* --- text helpers --------------------------------------------------------- */
.muted { color: var(--ink-mute); }
.faint { color: var(--ink-faint); }
.small { font-size: 0.85rem; }
.tiny { font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; }
.accent { color: var(--accent); }
.lower { text-transform: lowercase; }
.nowrap { white-space: nowrap; }
.ascii-rule {
color: var(--ink-faint);
white-space: pre;
overflow: hidden;
user-select: none;
line-height: 1;
}
/* --- lists ---------------------------------------------------------------- */
.bullets {
list-style: none;
padding: 0;
margin: 0.85rem 0 0;
color: var(--ink-mute);
font-size: 0.92rem;
}
.bullets li {
padding-left: 1.25rem;
position: relative;
}
.bullets li::before {
content: '\203A';
position: absolute;
left: 0;
color: var(--accent);
}
.def-list {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1rem;
row-gap: 0.35rem;
font-size: 0.9rem;
line-height: 1.5;
}
.def-list dt {
color: var(--ink-mute);
text-transform: lowercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.def-list dd {
margin: 0;
min-width: 0;
position: relative;
padding-left: 1rem;
}
.def-list dd::before {
content: '\203A';
position: absolute;
left: 0;
color: var(--accent);
}
@media (max-width: 480px) {
.def-list { grid-template-columns: 1fr; row-gap: 0.15rem; }
.def-list dt { margin-top: 0.4rem; }
.def-list dt:first-of-type { margin-top: 0; }
}
/* --- rendered markdown ---------------------------------------------------- */
.prose { font-size: 0.95rem; }
.prose > * + * { margin-top: 1rem; }
.prose h2,
.prose h3,
.prose h4 {
margin-top: 2rem;
text-transform: none;
color: var(--ink);
scroll-margin-top: 2rem;
}
.prose h2 { font-size: 1.15rem; }
.prose h2::before,
.prose h3::before {
content: '# ';
color: var(--accent);
}
.prose h3 { font-size: 1rem; }
.prose ul,
.prose ol { padding-left: 1.5rem; margin-top: 0.75rem; }
.prose li + li { margin-top: 0.25rem; }
.prose li::marker { color: var(--accent); }
.prose img {
display: block;
max-width: 100%;
height: auto;
border: 1px solid var(--ink);
}
.prose blockquote {
margin: 0;
border-left: 2px solid var(--accent);
padding-left: 1rem;
color: var(--ink-mute);
}
.prose code {
background: var(--paper-soft);
border: 1px solid var(--paper-soft);
padding: 0.05em 0.35em;
font-size: 0.9em;
}
.prose pre {
border: 1px solid var(--ink);
padding: 1rem;
overflow-x: auto;
background: var(--paper-soft) !important;
font-size: 0.85rem;
line-height: 1.5;
}
.prose pre code {
background: none;
border: 0;
padding: 0;
font-size: inherit;
}
.prose table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
display: block;
overflow-x: auto;
}
.prose th,
.prose td {
border: 1px solid var(--ink-faint);
padding: 0.35rem 0.6rem;
text-align: left;
}
.prose .heading-anchor {
text-decoration: none;
color: var(--ink-faint);
opacity: 0;
padding-left: 0.4rem;
}
.prose :is(h2, h3, h4):hover .heading-anchor { opacity: 1; }
/* shiki emits both themes; swap them with the css variable trick */
html[data-theme='dark'] .shiki,
html[data-theme='dark'] .shiki span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
font-style: var(--shiki-dark-font-style) !important;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .shiki,
html:not([data-theme]) .shiki span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
}
/* --- forms & buttons ------------------------------------------------------
Used by /dash. Same rules as the rest of the page: 1px ink, no radius,
monospace, inversion on interaction.
--------------------------------------------------------------------------- */
.field {
display: block;
}
.field > .label {
display: block;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--ink-mute);
margin-bottom: 0.25rem;
}
.input,
.textarea,
.select {
width: 100%;
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--paper-soft);
border: 1px solid var(--ink-faint);
padding: 0.35rem 0.5rem;
border-radius: 0;
-webkit-appearance: none;
appearance: none;
}
.input:focus,
.textarea:focus,
.select:focus {
outline: none;
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.textarea {
resize: vertical;
line-height: 1.55;
tab-size: 2;
}
.select {
/* room for the caret we draw with the wrapper */
padding-right: 1.5rem;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
cursor: pointer;
user-select: none;
}
.checkbox input {
appearance: none;
width: 1rem;
height: 1rem;
border: 1px solid var(--ink-faint);
background: var(--paper-soft);
display: grid;
place-content: center;
cursor: pointer;
margin: 0;
}
.checkbox input:checked {
border-color: var(--accent);
}
.checkbox input:checked::after {
content: '×';
color: var(--accent);
font-size: 0.9rem;
line-height: 1;
}
.btn {
font: inherit;
font-size: 0.85rem;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--ink);
padding: 0.3rem 0.75rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
border-radius: 0;
}
.btn:hover:not(:disabled) {
background: var(--ink);
color: var(--paper);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
border-color: var(--accent);
color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
color: var(--paper);
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
border-color: #c0392b;
color: #fff;
}
.btn-ghost {
border-color: transparent;
padding-inline: 0.35rem;
}
.btn-ghost:hover:not(:disabled) {
background: var(--ink);
color: var(--paper);
}
/* inline status line: ok / working / failed */
.flash {
font-size: 0.8rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--ink-faint);
}
.flash-ok {
border-color: var(--accent);
color: var(--accent);
}
.flash-error {
border-color: #c0392b;
color: #c0392b;
}

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
const { site } = useSiteData()
const banner = String.raw`███╗ ██╗ █████╗ ██╗ ██╗ ██████╗ ██╗ ██╗
████╗ ██║██╔══██╗██║ ██╔╝██╔═████╗╚██╗██╔╝
██╔██╗ ██║███████║█████╔╝ ██║██╔██║ ╚███╔╝
██║╚██╗██║██╔══██║██╔═██╗ ████╔╝██║ ██╔██╗
██║ ╚████║██║ ██║██║ ██╗╚██████╔╝██╔╝ ██╗
╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝`
</script>
<template>
<section class="hero">
<div class="frame hero-frame">
<div class="banner-wrap">
<pre class="banner" aria-label="nak0x">{{ banner }}</pre>
</div>
<hr>
<div class="sub-row">
<span class="domain">{{ site.domain }}</span>
<span class="sep">//</span>
<span class="muted">{{ site.role }}</span>
<span class="sep">//</span>
<span class="muted">{{ site.location }}</span>
</div>
</div>
</section>
</template>
<style scoped>
.hero {
margin: 1.5rem 0 2rem;
}
.hero-frame {
padding: 1.25rem 1.5rem;
overflow: hidden;
container-type: inline-size;
}
.banner-wrap {
position: relative;
width: 100%;
overflow: hidden;
}
.banner {
display: block;
font-size: clamp(5px, 1.85cqi, 15px);
line-height: 1.05;
letter-spacing: 0;
color: var(--ink);
white-space: pre;
text-align: center;
margin: 0.5rem auto;
}
.sub-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
font-size: 0.95rem;
}
.domain {
color: var(--accent);
font-weight: 600;
}
.sep {
color: var(--ink-faint);
}
hr {
margin: 0.75rem 0;
}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ char?: string; label?: string }>(), {
char: '*',
})
</script>
<template>
<div class="rule-row" aria-hidden="true">
<span class="ascii-rule fill">{{ props.char.repeat(200) }}</span>
<span v-if="props.label" class="label tiny muted">{{ props.label }}</span>
<span v-if="props.label" class="ascii-rule fill">{{ props.char.repeat(200) }}</span>
</div>
</template>
<style scoped>
.rule-row {
display: flex;
align-items: center;
gap: 0.75rem;
overflow: hidden;
margin: 2rem 0;
}
.fill {
flex: 1;
min-width: 0;
}
.label {
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { DashSession } from '#shared/types/dash'
import { dashLogout } from '~/composables/useDash'
const props = defineProps<{ session: DashSession }>()
const emit = defineEmits<{ 'signed-out': [] }>()
async function logout() {
await dashLogout()
emit('signed-out')
await navigateTo('/dash')
}
</script>
<template>
<header class="dash-head">
<div class="row title-row">
<h1>/dash</h1>
<span v-if="props.session.repo" class="muted small nowrap">
{{ props.session.provider }} · {{ props.session.repo }}@{{ props.session.branch }}
</span>
<span
v-if="props.session.authenticated && !props.session.writable"
class="flash flash-error tiny"
>
read-only — no CONTENT_TOKEN
</span>
</div>
<nav v-if="props.session.authenticated" class="row nav small">
<NuxtLink to="/dash">posts</NuxtLink>
<NuxtLink to="/dash/site">portfolio</NuxtLink>
<a href="/" target="_blank" rel="noopener">view site ↗</a>
<button class="btn btn-ghost" type="button" @click="logout">logout</button>
</nav>
</header>
</template>
<style scoped>
.dash-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin: 2rem 0 1.5rem;
}
h1 {
font-size: 1.4rem;
}
.title-row {
align-items: center;
gap: 0.75rem;
}
.nav {
gap: 1rem;
align-items: center;
}
.nav a.router-link-exact-active {
color: var(--accent);
}
</style>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { ProjectLink } from '#shared/site'
const model = defineModel<ProjectLink[]>({ required: true })
const props = withDefaults(defineProps<{ addLabel?: string }>(), { addLabel: 'add link' })
function update(index: number, key: keyof ProjectLink, value: string) {
model.value = model.value.map((link, i) => (i === index ? { ...link, [key]: value } : link))
}
function remove(index: number) {
model.value = model.value.filter((_, i) => i !== index)
}
function add() {
model.value = [...model.value, { label: '', href: '' }]
}
</script>
<template>
<div class="list">
<div v-for="(link, index) in model" :key="index" class="pair">
<input
class="input label-input"
type="text"
placeholder="label"
:value="link.label"
@input="update(index, 'label', ($event.target as HTMLInputElement).value)"
>
<input
class="input"
type="text"
placeholder="https://…"
:value="link.href"
@input="update(index, 'href', ($event.target as HTMLInputElement).value)"
>
<button class="btn btn-ghost" type="button" title="remove" @click="remove(index)">×</button>
</div>
<button class="btn" type="button" @click="add">+ {{ props.addLabel }}</button>
</div>
</template>
<style scoped>
.list {
display: grid;
gap: 0.5rem;
}
.pair {
display: flex;
gap: 0.35rem;
align-items: center;
}
.pair > .input {
flex: 1;
min-width: 0;
}
.label-input {
max-width: 9rem;
}
</style>

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import { dashLogin, errorText } from '~/composables/useDash'
const props = defineProps<{ enabled: boolean; handle: string }>()
const emit = defineEmits<{ done: [] }>()
const password = ref('')
const busy = ref(false)
const error = ref('')
async function submit() {
if (!password.value || busy.value) return
busy.value = true
error.value = ''
try {
await dashLogin(password.value)
password.value = ''
emit('done')
} catch (e) {
error.value = errorText(e, 'could not sign in')
} finally {
busy.value = false
}
}
</script>
<template>
<div class="frame login">
<span class="frame-tag">auth</span>
<template v-if="props.enabled">
<p class="small muted intro">
<span class="accent">$</span> sudo -u {{ props.handle }} edit site
</p>
<form class="form" @submit.prevent="submit">
<label class="field">
<span class="label">password</span>
<input
v-model="password"
class="input"
type="password"
autocomplete="current-password"
:disabled="busy"
>
</label>
<button class="btn btn-primary" type="submit" :disabled="busy || !password">
{{ busy ? 'checking…' : 'unlock' }}
</button>
</form>
<p v-if="error" class="flash flash-error err">{{ error }}</p>
</template>
<p v-else class="small muted">
the dashboard is off. set <code>DASH_PASSWORD</code> in the environment and restart.
</p>
</div>
</template>
<style scoped>
.login {
max-width: 26rem;
margin: 3rem auto;
}
.intro {
margin-bottom: 1rem;
}
.form {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.form .field {
flex: 1;
}
.err {
margin-top: 0.75rem;
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
const model = defineModel<string[]>({ required: true })
const props = withDefaults(
defineProps<{ placeholder?: string; multiline?: boolean; addLabel?: string }>(),
{ placeholder: '', multiline: false, addLabel: 'add' },
)
function update(index: number, value: string) {
const next = [...model.value]
next[index] = value
model.value = next
}
function remove(index: number) {
model.value = model.value.filter((_, i) => i !== index)
}
function move(index: number, delta: number) {
const target = index + delta
if (target < 0 || target >= model.value.length) return
const next = [...model.value]
const [item] = next.splice(index, 1)
next.splice(target, 0, item as string)
model.value = next
}
function add() {
model.value = [...model.value, '']
}
</script>
<template>
<div class="list">
<div v-for="(item, index) in model" :key="index" class="row-item">
<textarea
v-if="props.multiline"
class="textarea"
rows="3"
:value="item"
:placeholder="props.placeholder"
@input="update(index, ($event.target as HTMLTextAreaElement).value)"
/>
<input
v-else
class="input"
type="text"
:value="item"
:placeholder="props.placeholder"
@input="update(index, ($event.target as HTMLInputElement).value)"
>
<div class="controls">
<button class="btn btn-ghost" type="button" title="move up" @click="move(index, -1)">↑</button>
<button class="btn btn-ghost" type="button" title="move down" @click="move(index, 1)">↓</button>
<button class="btn btn-ghost" type="button" title="remove" @click="remove(index)">×</button>
</div>
</div>
<button class="btn" type="button" @click="add">+ {{ props.addLabel }}</button>
</div>
</template>
<style scoped>
.list {
display: grid;
gap: 0.5rem;
}
.row-item {
display: flex;
gap: 0.35rem;
align-items: flex-start;
}
.row-item > :first-child {
flex: 1;
min-width: 0;
}
.controls {
display: flex;
gap: 0.1rem;
}
.controls .btn {
padding: 0.3rem 0.4rem;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** the little label that sits on the top border */
tag?: string
/** highlight the border in the accent color on hover — use it when the box is a link */
hover?: boolean
/** internal route, external url, or nothing */
to?: string
}>(),
{ hover: false },
)
// resolveComponent has to run in setup, not lazily inside a computed
const NuxtLinkComponent = resolveComponent('NuxtLink')
const isExternal = computed(() => !!props.to && /^(https?:|mailto:)/i.test(props.to))
const component = computed(() => {
if (!props.to) return 'div'
return isExternal.value ? 'a' : NuxtLinkComponent
})
const bindings = computed(() => {
if (!props.to) return {}
return isExternal.value
? { href: props.to, target: '_blank', rel: 'noopener noreferrer' }
: { to: props.to }
})
</script>
<template>
<component
:is="component"
v-bind="bindings"
class="frame frame-box"
:class="{ 'accent-on-hover': props.hover }"
>
<span v-if="props.tag" class="frame-tag">{{ props.tag }}</span>
<slot />
</component>
</template>
<style scoped>
.frame-box {
display: block;
color: var(--ink);
text-decoration: none;
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import type { PostMeta } from '#shared/types/blog'
const props = defineProps<{ post: PostMeta }>()
const stamp = computed(() =>
new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: 'UTC' }).format(
new Date(props.post.date),
),
)
</script>
<template>
<NuxtLink :to="`/blog/${props.post.slug}`" class="post-row">
<div class="line">
<span class="stamp muted small">{{ stamp }}</span>
<span class="title">{{ props.post.title }}</span>
<span v-if="props.post.draft" class="badge accent tiny">draft</span>
<span class="dots" aria-hidden="true" />
<span class="time faint small nowrap">{{ props.post.readingTime }} min</span>
</div>
<p class="desc muted small">{{ props.post.description }}</p>
<div v-if="props.post.tags.length" class="tags faint tiny">
<span v-for="tag in props.post.tags" :key="tag">#{{ tag }}</span>
</div>
</NuxtLink>
</template>
<style scoped>
.post-row {
display: block;
text-decoration: none;
color: var(--ink);
padding: 0.85rem 0.5rem;
border-bottom: 1px dashed var(--ink-faint);
}
.post-row:hover {
background: var(--paper-soft);
}
.post-row:hover .title {
color: var(--accent);
}
.line {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.stamp {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.title {
font-weight: 600;
}
.badge {
border: 1px solid var(--accent);
padding: 0 0.3rem;
}
.dots {
flex: 1;
min-width: 1rem;
border-bottom: 1px dotted var(--ink-faint);
transform: translateY(-0.25em);
}
.desc {
margin-top: 0.25rem;
}
.tags {
margin-top: 0.35rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
@media (max-width: 560px) {
.dots { display: none; }
.line { flex-wrap: wrap; }
}
</style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import type { Project } from '#shared/site'
import type { RepoProject } from '#shared/types/projects'
const props = defineProps<{ project: Project | RepoProject; compact?: boolean }>()
const statusGlyph: Record<NonNullable<Project['status']>, string> = {
active: '●',
wip: '◐',
shipped: '✓',
archived: '□',
}
const sourceGlyph: Record<RepoProject['source'], string> = {
github: 'gh',
gitea: 'gitea',
manual: '',
}
/** repo metadata is absent on hand-written entries */
const repo = computed(() => ('source' in props.project ? props.project : null))
</script>
<template>
<FrameBox :to="props.project.href" hover :tag="props.project.year" class="card">
<div class="head">
<h2>{{ props.project.name }}</h2>
<span class="arrow muted">↗</span>
</div>
<p class="muted small tagline">{{ props.project.tagline }}</p>
<ul v-if="!props.compact && props.project.bullets?.length" class="bullets">
<li v-for="bullet in props.project.bullets" :key="bullet">{{ bullet }}</li>
</ul>
<div class="foot row small">
<span class="stack-line muted">{{ props.project.stack.join(' · ') }}</span>
<span class="meta faint">
<span v-if="repo && sourceGlyph[repo.source]" class="origin">
{{ sourceGlyph[repo.source] }}
</span>
<span v-if="repo && repo.stars" class="stars">★ {{ repo.stars }}</span>
<span v-if="props.project.status" class="status">
<span class="accent">{{ statusGlyph[props.project.status] }}</span>
{{ props.project.status }}
</span>
</span>
</div>
</FrameBox>
</template>
<style scoped>
.card {
height: 100%;
display: flex;
flex-direction: column;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
h2 {
font-size: 1.05rem;
text-transform: lowercase;
letter-spacing: -0.01em;
}
.tagline {
margin-top: 0.35rem;
}
.foot {
margin-top: auto;
padding-top: 1rem;
justify-content: space-between;
gap: 0.75rem;
}
.stack-line {
font-size: 0.8rem;
}
.meta {
display: flex;
align-items: baseline;
gap: 0.6rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
const { site } = useSiteData()
const year = new Date().getFullYear()
</script>
<template>
<footer class="foot">
<hr>
<div class="row foot-row small">
<span class="muted">© {{ year }} {{ site.name }} — no trackers, no cookies.</span>
<span class="links">
<a v-for="link in site.links" :key="link.href" :href="link.href">{{ link.label }}</a>
</span>
</div>
</footer>
</template>
<style scoped>
.foot {
margin-top: 3rem;
}
.foot-row {
justify-content: space-between;
}
.links {
display: inline-flex;
gap: 1rem;
flex-wrap: wrap;
}
hr {
margin: 2rem 0 1rem;
border-top: 1px solid var(--ink);
}
</style>

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
const { site } = useSiteData()
const route = useRoute()
const { theme, mounted, toggle } = useTheme()
const path = computed(() => {
const p = route.path.replace(/\/+$/, '')
return p === '' ? '~' : `~${p}`
})
const cmd = computed(() => {
const p = route.path
if (p === '/') return 'cat about.md'
if (p === '/projects') return 'ls -la ./projects'
if (p === '/blog') return 'ls ./posts'
if (p.startsWith('/blog/')) return `bat posts/${p.slice(6)}.md`
return `find . -path "${p}"`
})
const clock = ref('--:--:--')
let timer: ReturnType<typeof setInterval> | undefined
function tick() {
clock.value = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: site.value.timezone,
}).format(new Date())
}
onMounted(() => {
tick()
timer = setInterval(tick, 1000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="status">
<div class="status-left">
<span class="prompt">{{ site.handle }}@dev</span>
<span class="sep">:</span>
<span class="path">{{ path }}</span>
<span class="dollar">$</span>
<span class="cmd"><slot name="cmd">{{ cmd }}</slot></span>
<span class="cursor">_</span>
</div>
<div class="status-right small">
<NuxtLink v-for="item in site.nav" :key="item.href" :to="item.href">
{{ item.label }}
</NuxtLink>
<span class="sep">|</span>
<button class="theme-toggle" type="button" :aria-label="`switch to ${theme === 'dark' ? 'light' : 'dark'} theme`" @click="toggle">
<span class="theme-icon">{{ mounted && theme === 'dark' ? '◐' : '◑' }}</span>
<span>{{ mounted ? theme : 'theme' }}</span>
</button>
<span class="sep">|</span>
<span class="muted nowrap">{{ clock }}</span>
</div>
</div>
</template>
<style scoped>
.status {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
background: var(--paper);
flex-wrap: wrap;
gap: 0.5rem;
}
.status-left,
.status-right {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.status-right {
gap: 0.6rem;
}
.prompt { color: var(--accent); }
.sep { color: var(--ink-mute); }
.path { color: var(--ink); }
.dollar { color: var(--ink-mute); }
.cmd { color: var(--ink); }
.cursor {
display: inline-block;
color: var(--ink);
animation: blink 1s steps(1, end) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cursor { animation: none; }
}
.theme-toggle {
background: transparent;
border: 0;
padding: 0;
margin: 0;
color: var(--ink);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
.theme-toggle:hover {
background: var(--accent);
color: var(--paper);
text-decoration: none;
}
.theme-icon { color: var(--accent); }
.theme-toggle:hover .theme-icon { color: var(--paper); }
</style>

View File

@ -0,0 +1,27 @@
import type { DashSession } from '#shared/types/dash'
export const EMPTY_SESSION: DashSession = {
enabled: true,
authenticated: false,
writable: false,
}
export async function dashLogin(password: string): Promise<void> {
await $fetch('/api/dash/login', { method: 'POST', body: { password } })
}
export async function dashLogout(): Promise<void> {
await $fetch('/api/dash/logout', { method: 'POST' })
}
/** turn an h3 error into something worth showing a human */
export function errorText(error: unknown, fallback = 'something went wrong'): string {
const e = error as {
data?: { statusMessage?: string; message?: string }
statusMessage?: string
message?: string
}
return (
e?.data?.message || e?.data?.statusMessage || e?.message || e?.statusMessage || fallback
)
}

View File

@ -0,0 +1,31 @@
import { featuredOf } from '#shared/site'
import type { ProjectsPayload, RepoProject } from '#shared/types/projects'
const EMPTY: ProjectsPayload = { projects: [], sources: [] }
/** how many projects the home page shows when nothing is flagged featured */
const FEATURED_FALLBACK = 4
/**
* Every public repo of the configured github + gitea accounts, enriched by
* `site.json`. Keyed, so the home page and /projects share one fetch.
*/
export function useProjects() {
const { data, refresh } = useAsyncData<ProjectsPayload>(
'projects',
() => $fetch('/api/projects'),
{ default: () => EMPTY },
)
const projects = computed<RepoProject[]>(() => data.value?.projects ?? [])
// nothing flagged in site.json => the top of the sort is the selection
const featured = computed<RepoProject[]>(() => {
const flagged = featuredOf(projects.value) as RepoProject[]
return flagged.length ? flagged : projects.value.slice(0, FEATURED_FALLBACK)
})
const failed = computed(() => (data.value?.sources ?? []).filter((s) => !s.ok))
return { projects, featured, sources: computed(() => data.value?.sources ?? []), failed, refresh }
}

View File

@ -0,0 +1,16 @@
import { defaultSiteData, type SiteData } from '#shared/site'
/**
* Portfolio content lives in the content repo (`site.json`) so /dash can edit
* it. The seed compiled into the bundle is the fallback, which means the page
* still renders if the repo is unreachable.
*
* Keyed, so the layout and the page share one fetch.
*/
export function useSiteData() {
const { data, refresh } = useAsyncData<SiteData>('site', () => $fetch('/api/site'), {
default: () => defaultSiteData,
})
return { site: data, refresh }
}

View File

@ -0,0 +1,30 @@
type Theme = 'light' | 'dark'
/**
* The theme is applied by an inline script in `nuxt.config.ts` before first
* paint, so there is no flash. This composable only mirrors and flips it.
*/
export function useTheme() {
const theme = useState<Theme>('theme', () => 'light')
const mounted = useState<boolean>('theme:mounted', () => false)
onMounted(() => {
const current = document.documentElement.getAttribute('data-theme')
theme.value = current === 'dark' ? 'dark' : 'light'
mounted.value = true
})
function set(next: Theme) {
theme.value = next
document.documentElement.setAttribute('data-theme', next)
try {
localStorage.setItem('theme', next)
} catch {
// private mode, blocked storage — the toggle still works for this session
}
}
const toggle = () => set(theme.value === 'dark' ? 'light' : 'dark')
return { theme, mounted, set, toggle }
}

45
app/error.vue Normal file
View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const code = computed(() => props.error?.statusCode ?? 500)
const message = computed(
() => props.error?.message || props.error?.statusMessage || 'something went sideways',
)
</script>
<template>
<div class="paper">
<StatusBar />
<div class="frame err">
<span class="frame-tag">error</span>
<pre class="code accent">{{ code }}</pre>
<p class="muted">{{ message }}</p>
<p class="small muted back">
<NuxtLink to="/">cd ~</NuxtLink>
</p>
</div>
<SiteFooter />
</div>
</template>
<style scoped>
.err {
margin-top: 3rem;
text-align: center;
padding: 3rem 1.5rem;
}
.code {
font-size: clamp(3rem, 12vw, 6rem);
font-weight: 700;
line-height: 1;
}
.back {
margin-top: 1.5rem;
}
</style>

9
app/layouts/default.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<div class="paper">
<StatusBar />
<main>
<slot />
</main>
<SiteFooter />
</div>
</template>

14
app/middleware/dash.ts Normal file
View File

@ -0,0 +1,14 @@
import type { DashSession } from '#shared/types/dash'
/**
* Guards the editor pages. The real enforcement is server-side on every
* /api/dash route — this only spares you a page that would 401 on load.
*/
export default defineNuxtRouteMiddleware(async () => {
const requestFetch = useRequestFetch()
const session = await requestFetch<DashSession>('/api/dash/session').catch(() => null)
if (!session?.authenticated) {
return navigateTo('/dash')
}
})

195
app/pages/blog/[slug].vue Normal file
View File

@ -0,0 +1,195 @@
<script setup lang="ts">
import type { Post, PostMeta } from '#shared/types/blog'
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const { data, error } = await useFetch<{
post: Post
previous: PostMeta | null
next: PostMeta | null
}>(() => `/api/blog/${slug.value}`, { key: () => `post-${slug.value}` })
if (error.value) {
throw createError({
statusCode: error.value.statusCode ?? 500,
message: error.value.statusCode === 404 ? 'no such post' : 'could not load this post',
fatal: true,
})
}
const post = computed(() => data.value!.post)
const published = computed(() =>
new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
new Date(post.value.date),
),
)
const updated = computed(() =>
post.value.updated
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
new Date(post.value.updated),
)
: null,
)
useSeoMeta({
title: () => post.value.title,
description: () => post.value.description,
ogTitle: () => post.value.title,
ogDescription: () => post.value.description,
ogType: 'article',
articlePublishedTime: () => post.value.date,
articleTag: () => [...post.value.tags],
})
</script>
<template>
<article v-if="post">
<header class="head">
<p class="small muted crumbs">
<NuxtLink to="/blog">../blog</NuxtLink> / {{ post.slug }}.md
</p>
<h1>{{ post.title }}</h1>
<p class="muted small lede">{{ post.description }}</p>
<div class="row meta small faint">
<span>{{ published }}</span>
<span v-if="updated">· updated {{ updated }}</span>
<span>· {{ post.readingTime }} min</span>
<span v-if="post.tags.length">
·
<NuxtLink
v-for="tag in post.tags"
:key="tag"
:to="{ path: '/blog', query: { tag } }"
class="tag"
>#{{ tag }}</NuxtLink>
</span>
<span v-if="post.draft" class="accent">· draft</span>
</div>
</header>
<AsciiRule />
<div class="body">
<aside v-if="post.toc.length > 2" class="toc">
<div class="frame toc-frame">
<span class="frame-tag">contents</span>
<ul>
<li v-for="entry in post.toc" :key="entry.id" :class="`d${entry.depth}`">
<a :href="`#${entry.id}`">{{ entry.text }}</a>
</li>
</ul>
</div>
</aside>
<!-- rendered server-side from the repo's markdown -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="prose" v-html="post.html" />
</div>
<AsciiRule />
<nav class="neighbours grid-2">
<FrameBox v-if="data?.previous" :to="`/blog/${data.previous.slug}`" hover tag="older">
<span class="small">← {{ data.previous.title }}</span>
</FrameBox>
<span v-else />
<FrameBox v-if="data?.next" :to="`/blog/${data.next.slug}`" hover tag="newer" class="right">
<span class="small">{{ data.next.title }} →</span>
</FrameBox>
</nav>
<p v-if="post.sourceUrl" class="small faint source">
<a :href="post.sourceUrl" target="_blank" rel="noopener noreferrer">→ view the raw markdown</a>
</p>
</article>
</template>
<style scoped>
.head {
margin: 2rem 0 0;
}
.crumbs {
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.6rem;
letter-spacing: -0.02em;
}
.lede {
margin-top: 0.5rem;
}
.meta {
margin-top: 0.75rem;
gap: 0.35rem;
}
.tag {
margin-left: 0.35rem;
}
.body {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 940px) {
.body {
grid-template-columns: minmax(0, 1fr) 15rem;
}
.toc {
order: 2;
}
.toc-frame {
position: sticky;
top: 1.5rem;
}
}
.toc ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.85rem;
}
.toc li + li {
margin-top: 0.3rem;
}
.toc .d3 {
padding-left: 1rem;
}
.toc a {
text-decoration: none;
color: var(--ink-mute);
}
.toc a:hover {
color: var(--paper);
}
.neighbours {
align-items: stretch;
}
.right {
text-align: right;
}
.source {
margin-top: 1.5rem;
text-align: right;
}
</style>

108
app/pages/blog/index.vue Normal file
View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import type { PostMeta } from '#shared/types/blog'
const route = useRoute()
const activeTag = computed(() => (route.query.tag as string | undefined) ?? '')
const { data, error, status } = await useFetch<{
posts: PostMeta[]
tags: Array<{ tag: string; count: number }>
}>('/api/blog', {
key: 'blog-index',
query: { tag: activeTag },
default: () => ({ posts: [], tags: [] }),
})
const posts = computed(() => data.value?.posts ?? [])
const tags = computed(() => data.value?.tags ?? [])
useSeoMeta({
title: 'blog',
description: 'notes on rust, self-hosting, and making slow hardware feel fast.',
})
</script>
<template>
<div>
<header class="head">
<h1>blog</h1>
<p class="muted small">
written in markdown, pushed to a git repo, rendered here. no CMS was harmed.
</p>
</header>
<AsciiRule />
<nav v-if="tags.length" class="tags small">
<NuxtLink to="/blog" :class="{ active: !activeTag }">all</NuxtLink>
<NuxtLink
v-for="t in tags"
:key="t.tag"
:to="{ path: '/blog', query: { tag: t.tag } }"
:class="{ active: activeTag === t.tag }"
>
#{{ t.tag }} <span class="faint">{{ t.count }}</span>
</NuxtLink>
</nav>
<FrameBox :tag="activeTag ? `posts · #${activeTag}` : 'posts'" class="list">
<p v-if="error" class="muted small state">
the content repo did not answer ({{ error.statusCode ?? '???' }}). it will retry on its own.
</p>
<p v-else-if="status === 'pending' && !posts.length" class="muted small state">
reading the repo…
</p>
<p v-else-if="!posts.length" class="muted small state">
nothing here yet. push a <code>.md</code> to the content repo and it shows up.
</p>
<PostRow v-for="post in posts" :key="post.slug" :post="post" />
</FrameBox>
<p class="small muted feed">
<a href="/rss.xml">→ rss</a>
</p>
</div>
</template>
<style scoped>
.head {
margin: 2rem 0 0;
}
.head p {
margin-top: 0.35rem;
}
.tags {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.tags a {
text-decoration: none;
border: 1px solid var(--ink-faint);
padding: 0.1rem 0.5rem;
}
.tags a:hover,
.tags a.active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.list :deep(.post-row:last-child) {
border-bottom: 0;
}
.state {
padding: 1rem 0;
}
.feed {
margin-top: 1.25rem;
text-align: right;
}
</style>

283
app/pages/dash/index.vue Normal file
View File

@ -0,0 +1,283 @@
<script setup lang="ts">
import type { DashPost, DashSession } from '#shared/types/dash'
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
useSeoMeta({ title: 'dash', robots: 'noindex, nofollow' })
const requestFetch = useRequestFetch()
const { site } = useSiteData()
const { data, refresh } = await useAsyncData(
'dash:overview',
async () => {
const session = await requestFetch<DashSession>('/api/dash/session')
if (!session.authenticated) return { session, posts: [] as DashPost[] }
const { posts } = await requestFetch<{ posts: DashPost[] }>('/api/dash/posts')
return { session, posts }
},
{ default: () => ({ session: { ...EMPTY_SESSION }, posts: [] as DashPost[] }) },
)
const session = computed(() => data.value.session)
const posts = computed(() => data.value.posts)
const published = computed(() => posts.value.filter((p) => !p.draft).length)
const drafts = computed(() => posts.value.length - published.value)
const busy = ref(false)
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
const pendingDelete = ref<string | null>(null)
function say(text: string, kind: 'ok' | 'error' = 'ok') {
flash.value = { text, kind }
}
async function refreshCache() {
busy.value = true
flash.value = null
try {
const { cleared } = await $fetch<{ cleared: number }>('/api/dash/refresh', { method: 'POST' })
await refresh()
say(`re-read the repo, dropped ${cleared} cached ${cleared === 1 ? 'entry' : 'entries'}`)
} catch (e) {
say(errorText(e, 'could not refresh'), 'error')
} finally {
busy.value = false
}
}
async function confirmDelete(slug: string) {
busy.value = true
flash.value = null
try {
await $fetch(`/api/dash/posts/${slug}`, { method: 'DELETE' })
pendingDelete.value = null
await refresh()
say(`deleted ${slug}`)
} catch (e) {
say(errorText(e, 'could not delete'), 'error')
} finally {
busy.value = false
}
}
const stamp = (iso: string) =>
new Intl.DateTimeFormat('en-CA', { dateStyle: 'short', timeZone: 'UTC' }).format(new Date(iso))
</script>
<template>
<div>
<DashHeader :session="session" @signed-out="refresh" />
<DashLogin
v-if="!session.authenticated"
:enabled="session.enabled"
:handle="site.handle"
@done="refresh"
/>
<template v-else>
<section class="grid-2 top">
<FrameBox tag="source">
<dl class="def-list">
<dt>provider</dt>
<dd>{{ session.provider }}</dd>
<dt>repo</dt>
<dd>{{ session.repo }} <span class="faint">@{{ session.branch }}</span></dd>
<dt>posts in</dt>
<dd>{{ session.dir || '<repo root>' }}/</dd>
<dt>portfolio</dt>
<dd>{{ session.siteFile }}</dd>
<dt>writes</dt>
<dd :class="session.writable ? 'accent' : ''">
{{ session.writable ? 'enabled' : 'disabled — set CONTENT_TOKEN' }}
</dd>
<dt>webhook</dt>
<dd>{{ session.webhook ? 'configured' : 'not configured' }}</dd>
<dt>cache ttl</dt>
<dd>{{ session.ttl }}s</dd>
</dl>
</FrameBox>
<FrameBox tag="state">
<div class="counts">
<div>
<span class="count accent">{{ published }}</span>
<span class="muted small">published</span>
</div>
<div>
<span class="count">{{ drafts }}</span>
<span class="muted small">draft{{ drafts === 1 ? '' : 's' }}</span>
</div>
</div>
<div class="row actions">
<NuxtLink class="btn btn-primary" to="/dash/posts/new">+ new post</NuxtLink>
<button class="btn" type="button" :disabled="busy" @click="refreshCache">
re-read repo
</button>
</div>
<p v-if="flash" class="flash" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
</FrameBox>
</section>
<AsciiRule label="posts" />
<FrameBox tag="posts">
<p v-if="!posts.length" class="muted small empty">
no markdown found in <code>{{ session.repo }}/{{ session.dir }}</code>. create the first
one — it will be committed for you.
</p>
<table v-else class="posts">
<tbody>
<tr v-for="post in posts" :key="post.slug">
<td class="stamp muted small">{{ stamp(post.date) }}</td>
<td class="title">
<NuxtLink :to="`/dash/posts/${post.slug}`">{{ post.title }}</NuxtLink>
<span v-if="post.draft" class="badge accent tiny">draft</span>
</td>
<td class="tags faint tiny">
<span v-for="tag in post.tags" :key="tag">#{{ tag }}</span>
</td>
<td class="acts">
<template v-if="pendingDelete === post.slug">
<span class="small muted">delete?</span>
<button
class="btn btn-ghost btn-danger"
type="button"
:disabled="busy"
@click="confirmDelete(post.slug)"
>yes</button>
<button class="btn btn-ghost" type="button" @click="pendingDelete = null">no</button>
</template>
<template v-else>
<NuxtLink class="btn btn-ghost" :to="`/blog/${post.slug}`" target="_blank">view</NuxtLink>
<button
class="btn btn-ghost"
type="button"
:disabled="!session.writable"
:title="session.writable ? 'delete' : 'read-only'"
@click="pendingDelete = post.slug"
>×</button>
</template>
</td>
</tr>
</tbody>
</table>
</FrameBox>
</template>
</div>
</template>
<style scoped>
.top {
align-items: stretch;
}
.counts {
display: flex;
gap: 2rem;
margin-bottom: 1.25rem;
}
.counts > div {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.count {
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.actions {
gap: 0.5rem;
}
.flash {
margin-top: 1rem;
}
.empty {
padding: 1rem 0;
}
.posts {
width: 100%;
border-collapse: collapse;
}
.posts td {
padding: 0.5rem 0.4rem;
border-bottom: 1px dashed var(--ink-faint);
vertical-align: baseline;
}
.posts tr:last-child td {
border-bottom: 0;
}
.stamp {
white-space: nowrap;
font-variant-numeric: tabular-nums;
width: 1%;
}
.title a {
text-decoration: none;
font-weight: 600;
}
.title a:hover {
color: var(--accent);
background: none;
}
.badge {
border: 1px solid var(--accent);
padding: 0 0.3rem;
margin-left: 0.5rem;
}
.tags span + span {
margin-left: 0.4rem;
}
.acts {
text-align: right;
white-space: nowrap;
width: 1%;
}
.acts .btn {
text-decoration: none;
}
@media (max-width: 640px) {
.posts,
.posts tbody,
.posts tr,
.posts td {
display: block;
width: auto;
}
.posts tr {
padding: 0.5rem 0;
border-bottom: 1px dashed var(--ink-faint);
}
.posts td {
border: 0;
padding: 0.1rem 0;
}
.acts {
text-align: left;
margin-top: 0.35rem;
}
}
</style>

View File

@ -0,0 +1,458 @@
<script setup lang="ts">
import type { DashSession, PostDraft, PostFrontmatterInput, SaveResult } from '#shared/types/dash'
import type { TocEntry } from '#shared/types/blog'
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
definePageMeta({ middleware: 'dash' })
const route = useRoute()
const requestFetch = useRequestFetch()
const slug = computed(() => String(route.params.slug))
const isNew = computed(() => slug.value === 'new')
const today = new Date().toISOString().slice(0, 10)
function blankDraft(): PostDraft {
return {
path: '',
sha: '',
sourceUrl: '',
frontmatter: {
title: '',
description: '',
date: today,
updated: '',
tags: [],
slug: '',
draft: true,
},
body: '',
}
}
const { data } = await useAsyncData(
() => `dash:post:${slug.value}`,
async () => {
const session = await requestFetch<DashSession>('/api/dash/session')
if (isNew.value) return { session, draft: blankDraft() }
const draft = await requestFetch<PostDraft>(`/api/dash/posts/${slug.value}`)
return { session, draft }
},
{
watch: [slug],
default: () => ({ session: { ...EMPTY_SESSION }, draft: blankDraft() }),
},
)
const session = computed<DashSession>(() => data.value.session)
// --- editable state -------------------------------------------------------
const form = reactive<PostFrontmatterInput>({ ...blankDraft().frontmatter })
const body = ref('')
const sha = ref('')
const path = ref('')
const sourceUrl = ref('')
const message = ref('')
const tagsText = computed({
get: () => form.tags.join(', '),
set: (value: string) => {
form.tags = value.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
},
})
/** a stable snapshot, so "dirty" means the file would actually change */
const snapshot = ref('')
const fingerprint = () => JSON.stringify({ ...form, body: body.value })
const dirty = computed(() => fingerprint() !== snapshot.value)
function hydrate() {
const draft = data.value.draft
Object.assign(form, draft.frontmatter)
body.value = draft.body
sha.value = draft.sha
path.value = draft.path
sourceUrl.value = draft.sourceUrl
snapshot.value = fingerprint()
}
watch(data, hydrate, { immediate: true })
// --- preview --------------------------------------------------------------
type View = 'split' | 'edit' | 'preview'
const view = ref<View>('split')
const preview = ref<{ html: string; toc: TocEntry[]; readingTime: number } | null>(null)
const previewing = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function renderPreview() {
previewing.value = true
try {
preview.value = await $fetch('/api/dash/preview', {
method: 'POST',
body: { body: body.value, path: path.value },
})
} catch {
// a failed preview is not worth interrupting the writing for
} finally {
previewing.value = false
}
}
function schedulePreview() {
clearTimeout(timer)
timer = setTimeout(renderPreview, 450)
}
watch(body, () => {
if (view.value !== 'edit') schedulePreview()
})
watch(view, (v) => {
if (v !== 'edit' && !preview.value) renderPreview()
})
onMounted(renderPreview)
onBeforeUnmount(() => clearTimeout(timer))
// --- saving ---------------------------------------------------------------
const busy = ref(false)
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
const pendingDelete = ref(false)
const canSave = computed(() => session.value.writable && !busy.value && !!form.title.trim())
async function save() {
if (!canSave.value) return
busy.value = true
flash.value = null
const payload = {
frontmatter: { ...form },
body: body.value,
message: message.value,
sha: sha.value || undefined,
}
try {
const result = isNew.value
? await $fetch<SaveResult>('/api/dash/posts', { method: 'POST', body: payload })
: await $fetch<SaveResult>(`/api/dash/posts/${slug.value}`, { method: 'PUT', body: payload })
sha.value = result.sha
path.value = result.path
message.value = ''
snapshot.value = fingerprint()
flash.value = {
text: `committed ${result.path}${result.commit ? ` (${result.commit.slice(0, 7)})` : ''}`,
kind: 'ok',
}
if (result.slug !== slug.value) await navigateTo(`/dash/posts/${result.slug}`)
} catch (e) {
flash.value = { text: errorText(e, 'could not save'), kind: 'error' }
} finally {
busy.value = false
}
}
async function destroy() {
busy.value = true
flash.value = null
try {
await $fetch(`/api/dash/posts/${slug.value}`, {
method: 'DELETE',
body: { message: message.value },
})
snapshot.value = fingerprint() // stop the leave guard from firing
await navigateTo('/dash')
} catch (e) {
flash.value = { text: errorText(e, 'could not delete'), kind: 'error' }
busy.value = false
}
}
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return confirm('you have unsaved changes. leave anyway?')
})
useSeoMeta({
title: () => (isNew.value ? 'new post' : `edit ${slug.value}`),
robots: 'noindex, nofollow',
})
</script>
<template>
<div>
<DashHeader :session="session" />
<div class="bar">
<div class="row crumbs small muted">
<NuxtLink to="/dash">../dash</NuxtLink>
<span>/</span>
<span>{{ isNew ? 'new post' : path || slug }}</span>
<span v-if="dirty" class="accent tiny">● unsaved</span>
</div>
<div class="row tools">
<div class="views">
<button
v-for="option in (['edit', 'split', 'preview'] as const)"
:key="option"
class="btn btn-ghost"
:class="{ on: view === option }"
type="button"
@click="view = option"
>{{ option }}</button>
</div>
<a v-if="sourceUrl && !isNew" class="btn btn-ghost" :href="sourceUrl" target="_blank" rel="noopener">raw ↗</a>
<NuxtLink v-if="!isNew" class="btn btn-ghost" :to="`/blog/${form.slug || slug}`" target="_blank">view ↗</NuxtLink>
</div>
</div>
<FrameBox tag="frontmatter" class="meta">
<div class="meta-grid">
<label class="field span-2">
<span class="label">title</span>
<input v-model="form.title" class="input" type="text" placeholder="how this blog works">
</label>
<label class="field">
<span class="label">slug (url)</span>
<input v-model="form.slug" class="input" type="text" :placeholder="'auto from title'">
</label>
<label class="field">
<span class="label">date</span>
<input v-model="form.date" class="input" type="date">
</label>
<label class="field">
<span class="label">updated (optional)</span>
<input v-model="form.updated" class="input" type="date">
</label>
<label class="field span-2">
<span class="label">description</span>
<input v-model="form.description" class="input" type="text" placeholder="leave empty to use the first paragraph">
</label>
<label class="field span-2">
<span class="label">tags (comma separated)</span>
<input v-model="tagsText" class="input" type="text" placeholder="rust, self-hosting">
</label>
<label class="checkbox draft-toggle">
<input v-model="form.draft" type="checkbox">
<span>draft — hidden in production</span>
</label>
</div>
</FrameBox>
<div class="panes" :class="`view-${view}`">
<FrameBox v-if="view !== 'preview'" tag="markdown" class="pane">
<textarea
v-model="body"
class="textarea editor"
spellcheck="false"
placeholder="# heading&#10;&#10;write here."
/>
</FrameBox>
<FrameBox v-if="view !== 'edit'" tag="preview" class="pane">
<div class="pane-head small faint">
<span>{{ preview?.readingTime ?? 1 }} min read</span>
<span v-if="previewing" class="accent">rendering…</span>
</div>
<!-- server-rendered by the same pipeline the published page uses -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="prose preview-body" v-html="preview?.html ?? ''" />
</FrameBox>
</div>
<FrameBox tag="commit" class="commit">
<div class="commit-row">
<label class="field msg">
<span class="label">commit message</span>
<input
v-model="message"
class="input"
type="text"
:placeholder="isNew ? `post: add ${form.slug || 'new-post'}` : `post: update ${form.slug || slug}`"
>
</label>
<div class="row commit-actions">
<button class="btn btn-primary" type="button" :disabled="!canSave" @click="save">
{{ busy ? 'committing…' : isNew ? 'create' : 'save' }}
</button>
<template v-if="!isNew">
<template v-if="pendingDelete">
<span class="small muted">delete this post?</span>
<button class="btn btn-danger" type="button" :disabled="busy" @click="destroy">yes, delete</button>
<button class="btn btn-ghost" type="button" @click="pendingDelete = false">cancel</button>
</template>
<button
v-else
class="btn"
type="button"
:disabled="!session.writable || busy"
@click="pendingDelete = true"
>delete</button>
</template>
</div>
</div>
<p v-if="!session.writable" class="flash flash-error note">
no write token configured — set <code>CONTENT_TOKEN</code> to commit from here.
</p>
<p v-else-if="flash" class="flash note" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
</FrameBox>
</div>
</template>
<style scoped>
.bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.crumbs {
gap: 0.4rem;
align-items: baseline;
}
.tools {
gap: 0.5rem;
align-items: center;
}
.views {
display: inline-flex;
border: 1px solid var(--ink-faint);
}
.views .btn {
border: 0;
font-size: 0.8rem;
}
.views .btn.on {
background: var(--ink);
color: var(--paper);
}
.meta {
margin-bottom: 1.25rem;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
.span-2 {
grid-column: span 3;
}
.draft-toggle {
grid-column: span 3;
margin-top: 0.25rem;
}
@media (max-width: 720px) {
.meta-grid {
grid-template-columns: 1fr;
}
.span-2,
.draft-toggle {
grid-column: span 1;
}
}
.panes {
display: grid;
gap: 1.25rem;
align-items: stretch;
}
.panes.view-split {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 900px) {
.panes.view-split {
grid-template-columns: 1fr;
}
}
.pane {
min-width: 0;
display: flex;
flex-direction: column;
}
.editor {
min-height: 26rem;
height: 100%;
border: 0;
background: transparent;
padding: 0;
font-size: 0.88rem;
}
.editor:focus {
box-shadow: none;
}
.pane-head {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
border-bottom: 1px dashed var(--ink-faint);
}
.preview-body {
min-height: 24rem;
overflow-wrap: anywhere;
}
.commit {
margin-top: 1.25rem;
}
.commit-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.msg {
flex: 1;
min-width: 16rem;
}
.commit-actions {
gap: 0.5rem;
align-items: center;
}
.note {
margin-top: 0.75rem;
}
</style>

452
app/pages/dash/site.vue Normal file
View File

@ -0,0 +1,452 @@
<script setup lang="ts">
import { PROJECT_STATUSES, defaultSiteData, type Project, type SiteData } from '#shared/site'
import type { DashSession } from '#shared/types/dash'
import { EMPTY_SESSION, errorText } from '~/composables/useDash'
definePageMeta({ middleware: 'dash' })
useSeoMeta({ title: 'dash · portfolio', robots: 'noindex, nofollow' })
const requestFetch = useRequestFetch()
const { data } = await useAsyncData(
'dash:site',
async () => {
const [session, doc] = await Promise.all([
requestFetch<DashSession>('/api/dash/session'),
requestFetch<{ data: SiteData; sha: string | null; fromRepo: boolean }>('/api/dash/site'),
])
return { session, doc }
},
{
default: () => ({
session: { ...EMPTY_SESSION },
doc: { data: structuredClone(defaultSiteData), sha: null, fromRepo: false },
}),
},
)
const session = computed<DashSession>(() => data.value.session)
const form = ref<SiteData>(structuredClone(defaultSiteData))
const sha = ref<string | null>(null)
const fromRepo = ref(false)
const snapshot = ref('')
const message = ref('')
function hydrate() {
form.value = structuredClone(toRaw(data.value.doc.data))
sha.value = data.value.doc.sha
fromRepo.value = data.value.doc.fromRepo
snapshot.value = JSON.stringify(form.value)
}
watch(data, hydrate, { immediate: true })
const dirty = computed(() => JSON.stringify(form.value) !== snapshot.value)
// word lists (skills, stack) are edited as one comma-separated field
const joinList = (items: string[]) => items.join(', ')
const parseList = (value: string) => value.split(',').map((v) => v.trim()).filter(Boolean)
const inputValue = (event: Event) => (event.target as HTMLInputElement).value
// --- projects -------------------------------------------------------------
const openProject = ref<number | null>(null)
function blankProject(): Project {
return {
name: '',
tagline: '',
bullets: [],
stack: [],
year: String(new Date().getFullYear()),
href: '',
links: [],
featured: false,
hidden: false,
status: 'wip',
}
}
function addProject() {
form.value.projects = [...form.value.projects, blankProject()]
openProject.value = form.value.projects.length - 1
}
function removeProject(index: number) {
form.value.projects = form.value.projects.filter((_, i) => i !== index)
openProject.value = null
}
function moveProject(index: number, delta: number) {
const target = index + delta
if (target < 0 || target >= form.value.projects.length) return
const next = [...form.value.projects]
const [item] = next.splice(index, 1)
next.splice(target, 0, item as Project)
form.value.projects = next
openProject.value = target
}
// --- skills ---------------------------------------------------------------
function addSkillGroup() {
form.value.skills = [...form.value.skills, { label: '', items: [] }]
}
function removeSkillGroup(index: number) {
form.value.skills = form.value.skills.filter((_, i) => i !== index)
}
// --- saving ---------------------------------------------------------------
const busy = ref(false)
const flash = ref<{ text: string; kind: 'ok' | 'error' } | null>(null)
async function save() {
if (!session.value.writable || busy.value) return
busy.value = true
flash.value = null
try {
const result = await $fetch<{ sha: string; commit?: string; data: SiteData }>('/api/dash/site', {
method: 'PUT',
body: { data: form.value, sha: sha.value, message: message.value },
})
sha.value = result.sha
fromRepo.value = true
form.value = structuredClone(result.data)
snapshot.value = JSON.stringify(form.value)
message.value = ''
flash.value = {
text: `committed${result.commit ? ` ${result.commit.slice(0, 7)}` : ''} — the site is live with these values`,
kind: 'ok',
}
await refreshNuxtData('site')
} catch (e) {
flash.value = { text: errorText(e, 'could not save'), kind: 'error' }
} finally {
busy.value = false
}
}
function revert() {
hydrate()
flash.value = null
}
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return confirm('you have unsaved changes. leave anyway?')
})
</script>
<template>
<div>
<DashHeader :session="session" />
<div class="bar">
<p class="small muted">
writes <code>{{ session.siteFile }}</code> to {{ session.repo }}.
<span v-if="!fromRepo" class="accent">the repo has no {{ session.siteFile }} yet — saving creates it.</span>
</p>
<span v-if="dirty" class="accent tiny nowrap">● unsaved</span>
</div>
<FrameBox tag="identity" class="block">
<div class="grid">
<label class="field">
<span class="label">name</span>
<input v-model="form.name" class="input" type="text">
</label>
<label class="field">
<span class="label">handle</span>
<input v-model="form.handle" class="input" type="text">
</label>
<label class="field">
<span class="label">domain</span>
<input v-model="form.domain" class="input" type="text">
</label>
<label class="field">
<span class="label">role</span>
<input v-model="form.role" class="input" type="text">
</label>
<label class="field">
<span class="label">company</span>
<input v-model="form.company.name" class="input" type="text">
</label>
<label class="field">
<span class="label">company url</span>
<input v-model="form.company.href" class="input" type="text">
</label>
<label class="field">
<span class="label">location</span>
<input v-model="form.location" class="input" type="text">
</label>
<label class="field">
<span class="label">timezone (clock in the status bar)</span>
<input v-model="form.timezone" class="input" type="text" placeholder="Europe/Paris">
</label>
<label class="field">
<span class="label">contact email</span>
<input v-model="form.contactEmail" class="input" type="text">
</label>
</div>
</FrameBox>
<FrameBox tag="copy" class="block">
<div class="stack">
<label class="field">
<span class="label">tagline</span>
<input v-model="form.tagline" class="input" type="text">
</label>
<label class="field">
<span class="label">meta description (search results, og tags, rss)</span>
<textarea v-model="form.description" class="textarea" rows="3" />
</label>
<div class="field">
<span class="label">about — one paragraph per entry</span>
<DashStringList v-model="form.about" multiline add-label="paragraph" />
</div>
</div>
</FrameBox>
<FrameBox tag="skills" class="block">
<div class="stack">
<div v-for="(group, index) in form.skills" :key="index" class="skill-row">
<input v-model="group.label" class="input group-label" type="text" placeholder="languages">
<input
class="input"
type="text"
placeholder="rust, typescript, python"
:value="joinList(group.items)"
@input="group.items = parseList(inputValue($event))"
>
<button class="btn btn-ghost" type="button" title="remove" @click="removeSkillGroup(index)">×</button>
</div>
<button class="btn" type="button" @click="addSkillGroup">+ group</button>
</div>
</FrameBox>
<FrameBox tag="links" class="block">
<p class="small muted hint">shown in the footer and in the about box. relative paths work too (<code>/rss.xml</code>).</p>
<DashLinkList v-model="form.links" />
</FrameBox>
<FrameBox tag="projects" class="block">
<p class="small muted hint">
the public list is every repo on github + gitea. these entries only override the ones
whose <code>href</code> or <code>name</code> matches — bullets, a nicer name, the featured
flag. an entry that matches nothing is shown as-is.
</p>
<div class="stack">
<div v-for="(project, index) in form.projects" :key="index" class="project">
<div class="project-head">
<button
class="btn btn-ghost expand"
type="button"
@click="openProject = openProject === index ? null : index"
>
<span class="accent">{{ openProject === index ? '−' : '+' }}</span>
<span class="pname">{{ project.name || 'untitled' }}</span>
<span class="faint tiny">
{{ project.status }}{{ project.featured ? ' · featured' : ''
}}{{ project.hidden ? ' · hidden' : '' }}
</span>
</button>
<div class="project-controls">
<button class="btn btn-ghost" type="button" title="move up" @click="moveProject(index, -1)">↑</button>
<button class="btn btn-ghost" type="button" title="move down" @click="moveProject(index, 1)">↓</button>
<button class="btn btn-ghost" type="button" title="remove" @click="removeProject(index)">×</button>
</div>
</div>
<div v-if="openProject === index" class="project-body">
<div class="grid">
<label class="field">
<span class="label">name</span>
<input v-model="project.name" class="input" type="text">
</label>
<label class="field">
<span class="label">year</span>
<input v-model="project.year" class="input" type="text" placeholder="2026 / ongoing">
</label>
<label class="field">
<span class="label">status</span>
<select v-model="project.status" class="select">
<option v-for="value in PROJECT_STATUSES" :key="value" :value="value">{{ value }}</option>
</select>
</label>
</div>
<label class="field">
<span class="label">tagline</span>
<input v-model="project.tagline" class="input" type="text">
</label>
<label class="field">
<span class="label">url</span>
<input v-model="project.href" class="input" type="text">
</label>
<label class="field">
<span class="label">stack (comma separated)</span>
<input
class="input"
type="text"
:value="joinList(project.stack)"
@input="project.stack = parseList(inputValue($event))"
>
</label>
<div class="field">
<span class="label">bullets — shown on featured cards</span>
<DashStringList v-model="project.bullets" add-label="bullet" />
</div>
<div class="field">
<span class="label">extra links</span>
<DashLinkList v-model="project.links" />
</div>
<label class="checkbox">
<input v-model="project.featured" type="checkbox">
<span>featured on the home page</span>
</label>
<label class="checkbox">
<input v-model="project.hidden" type="checkbox">
<span>hide — keep this repo off the site</span>
</label>
</div>
</div>
<button class="btn" type="button" @click="addProject">+ project</button>
</div>
</FrameBox>
<FrameBox tag="commit" class="block">
<div class="commit-row">
<label class="field msg">
<span class="label">commit message</span>
<input v-model="message" class="input" type="text" placeholder="site: update portfolio content">
</label>
<div class="row commit-actions">
<button class="btn btn-primary" type="button" :disabled="!session.writable || busy || !dirty" @click="save">
{{ busy ? 'committing…' : 'save' }}
</button>
<button class="btn" type="button" :disabled="!dirty || busy" @click="revert">revert</button>
</div>
</div>
<p v-if="!session.writable" class="flash flash-error note">
no write token configured — set <code>CONTENT_TOKEN</code> to commit from here.
</p>
<p v-else-if="flash" class="flash note" :class="`flash-${flash.kind}`">{{ flash.text }}</p>
</FrameBox>
</div>
</template>
<style scoped>
.bar {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.block {
margin-bottom: 1.25rem;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
@media (max-width: 760px) {
.grid {
grid-template-columns: 1fr;
}
}
.hint {
margin-bottom: 0.75rem;
}
.skill-row {
display: flex;
gap: 0.35rem;
align-items: center;
}
.skill-row > .input:not(.group-label) {
flex: 1;
min-width: 0;
}
.group-label {
max-width: 9rem;
}
.project {
border: 1px solid var(--ink-faint);
}
.project-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.25rem 0.35rem;
}
.expand {
gap: 0.5rem;
flex: 1;
justify-content: flex-start;
text-align: left;
}
.pname {
font-weight: 600;
}
.project-controls {
display: flex;
gap: 0.1rem;
}
.project-body {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
border-top: 1px dashed var(--ink-faint);
}
.commit-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.msg {
flex: 1;
min-width: 16rem;
}
.commit-actions {
gap: 0.5rem;
}
.note {
margin-top: 0.75rem;
}
</style>

130
app/pages/index.vue Normal file
View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import type { PostMeta } from '#shared/types/blog'
const { site } = useSiteData()
const { featured } = useProjects()
const { data } = await useFetch<{ posts: PostMeta[] }>('/api/blog', {
key: 'home-posts',
default: () => ({ posts: [] }),
})
const latest = computed(() => (data.value?.posts ?? []).slice(0, 3))
useSeoMeta({
title: '',
description: () => site.value.description,
ogTitle: () => site.value.domain,
ogDescription: () => site.value.description,
})
</script>
<template>
<div>
<AsciiBanner />
<section id="about" class="grid-2">
<FrameBox tag="/about">
<div class="stack-sm">
<p v-for="line in site.about" :key="line" class="small">{{ line }}</p>
</div>
<dl class="def-list about-meta">
<dt>where</dt>
<dd>{{ site.location }}</dd>
<dt>work</dt>
<dd>
{{ site.role }} @
<a :href="site.company.href" target="_blank" rel="noopener noreferrer">{{ site.company.name }}</a>
</dd>
<dt>elsewhere</dt>
<dd>
<span class="inline-links">
<a v-for="link in site.links" :key="link.href" :href="link.href">{{ link.label }}</a>
</span>
</dd>
</dl>
</FrameBox>
<FrameBox tag="/skills">
<dl class="def-list">
<template v-for="group in site.skills" :key="group.label">
<dt>{{ group.label }}</dt>
<dd>{{ group.items.join(', ') }}</dd>
</template>
</dl>
<p class="small muted note">
the list is descriptive, not aspirational — everything here shipped something.
</p>
</FrameBox>
</section>
<AsciiRule label="selected work" />
<section class="grid-2">
<ProjectCard v-for="project in featured" :key="project.href" :project="project" />
</section>
<p class="more small">
<NuxtLink to="/projects">→ everything else</NuxtLink>
</p>
<AsciiRule label="writing" />
<section>
<FrameBox tag="/blog">
<template v-if="latest.length">
<PostRow v-for="post in latest" :key="post.slug" :post="post" />
<p class="more small">
<NuxtLink to="/blog">→ all posts</NuxtLink>
</p>
</template>
<p v-else class="muted small empty">
nothing published yet. the feed is wired to the content repo — the first push fills it.
</p>
</FrameBox>
</section>
<AsciiRule label="contact" />
<FrameBox tag="/contact" class="contact">
<p class="small">
mail is the fastest way in.
<a :href="`mailto:${site.contactEmail}`">{{ site.contactEmail }}</a>
</p>
<p class="small muted">
patches, questions and "your site broke on my machine" reports all welcome.
</p>
</FrameBox>
</div>
</template>
<style scoped>
.about-meta {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px dashed var(--ink-faint);
}
.inline-links {
display: inline-flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.note {
margin-top: 1.25rem;
}
.more {
margin-top: 1rem;
text-align: right;
}
.empty {
padding: 1rem 0;
}
.contact p + p {
margin-top: 0.5rem;
}
</style>

78
app/pages/projects.vue Normal file
View File

@ -0,0 +1,78 @@
<script setup lang="ts">
const { site } = useSiteData()
const { projects, failed } = useProjects()
const groups = computed(() => {
const all = projects.value
const active = all.filter((p) => p.status === 'active' || p.status === 'wip')
const rest = all.filter((p) => p.status !== 'active' && p.status !== 'wip')
return [
{ label: 'in flight', items: active },
{ label: 'the shelf', items: rest },
].filter((g) => g.items.length)
})
useSeoMeta({
title: 'projects',
description: 'every public repo nak0x owns, on github and on his own gitea.',
})
</script>
<template>
<div>
<header class="head">
<h1>projects</h1>
<p class="muted small">
{{ projects.length }} public repositories, read straight off github and gitea.
most of them started as "this should take an evening".
</p>
</header>
<p v-if="failed.length" class="small faint warn">
{{ failed.map((s) => s.kind).join(' and ') }} did not answer — this list is short a few
entries.
</p>
<template v-for="group in groups" :key="group.label">
<AsciiRule :label="group.label" />
<section class="grid-2">
<ProjectCard v-for="project in group.items" :key="project.href" :project="project" />
</section>
</template>
<p v-if="!projects.length" class="muted small empty">
no repositories came back. either both forges are down, or the accounts are misconfigured.
</p>
<AsciiRule />
<p class="small muted">
more of it lives on
<a
v-for="(link, i) in site.links.filter((l) => l.href.startsWith('http'))"
:key="link.href"
:href="link.href"
target="_blank"
rel="noopener noreferrer"
>{{ i ? ', ' : '' }}{{ link.label }}</a>.
</p>
</div>
</template>
<style scoped>
.head {
margin: 2rem 0 0;
}
.head p {
margin-top: 0.35rem;
}
.warn {
margin-top: 0.75rem;
}
.empty {
margin-top: 1rem;
}
</style>

View File

@ -0,0 +1,59 @@
---
title: how this blog works
description: markdown in a git repo, a webhook, and no build step. here is the whole thing.
date: 2026-09-08
tags: [meta, nuxt, gitea]
draft: false
---
This post is a file. It lives in a git repo, it has YAML frontmatter, and pushing
it is the entire publishing pipeline. No CMS, no rebuild, no `npm run deploy`.
## the shape of a post
Everything above the second `---` is frontmatter:
```yaml
---
title: how this blog works # required-ish; falls back to the filename
description: one line, used in the # optional; falls back to the first paragraph
list view and the og tags
date: 2026-09-08 # optional; falls back to a YYYY-MM-DD filename prefix
updated: 2026-09-09 # optional
tags: [meta, nuxt, gitea] # optional; also accepts "meta, nuxt"
slug: how-this-blog-works # optional; falls back to the filename
draft: false # true hides it in production
---
```
The filename becomes the slug, with a leading date stripped:
`2026-09-08-hello.md` → `/blog/hello`.
## how it gets here
1. You push to the content repo.
2. Gitea fires a webhook at `/api/webhook/content` with an HMAC signature.
3. The server checks the signature, drops its cache, and re-reads the tree.
4. The next request renders from the fresh copy.
If the webhook never fires — network hiccup, misconfigured secret — the cache
still expires on its own after `CONTENT_TTL` seconds. And if the repo is down
when a refresh is due, the last good copy keeps being served instead of a 502.
## code and images
Fenced blocks are highlighted server-side by Shiki, in both themes at once:
```rust
fn main() {
let payload = std::fs::read_to_string("post.md").expect("a post");
println!("{} bytes, zero javascript", payload.len());
}
```
Relative image paths resolve against the post's own directory in the repo, so
`![a diagram](./diagram.png)` works if you commit the png next to the markdown.
> Blockquotes, tables, and lists all render. Anything markdown-it understands.
That's it. Write a file, push, done.

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
# Local parity with what Coolify runs. `docker compose up --build`.
services:
folio:
build:
context: .
args:
SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:3000}
image: nak0x-folio
restart: unless-stopped
ports:
- '3000:3000'
environment:
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:3000}
CONTENT_PROVIDER: ${CONTENT_PROVIDER:-gitea}
CONTENT_REPO: ${CONTENT_REPO:-Nak0x/Journal}
CONTENT_BRANCH: ${CONTENT_BRANCH:-main}
CONTENT_DIR: ${CONTENT_DIR:-posts}
GITEA_URL: ${GITEA_URL:-https://git.nak0x.dev}
CONTENT_TOKEN: ${CONTENT_TOKEN:-}
CONTENT_TTL: ${CONTENT_TTL:-600}
CONTENT_WEBHOOK_SECRET: ${CONTENT_WEBHOOK_SECRET:-}
CONTENT_SITE_FILE: ${CONTENT_SITE_FILE:-site.json}
DASH_PASSWORD: ${DASH_PASSWORD:-}
DASH_SESSION_SECRET: ${DASH_SESSION_SECRET:-}
DASH_SESSION_HOURS: ${DASH_SESSION_HOURS:-168}

88
nuxt.config.ts Normal file
View File

@ -0,0 +1,88 @@
export default defineNuxtConfig({
compatibilityDate: '2026-09-01',
devtools: { enabled: false },
ssr: true,
future: { compatibilityVersion: 4 },
css: ['~/assets/css/main.css'],
nitro: {
preset: 'node-server',
compressPublicAssets: true,
},
runtimeConfig: {
// --- blog source (server only) ---------------------------------------
content: {
// 'gitea' | 'github' | 'local'
provider: process.env.CONTENT_PROVIDER || 'gitea',
// "owner/repo"
repo: process.env.CONTENT_REPO || 'Nak0x/Journal',
branch: process.env.CONTENT_BRANCH || 'main',
// sub-directory inside the repo holding the .md files
dir: process.env.CONTENT_DIR || 'posts',
// base url of the gitea instance (ignored for github)
giteaUrl: process.env.GITEA_URL || 'https://git.nak0x.dev',
// optional PAT — only needed for private repos / gh rate-limits
token: process.env.CONTENT_TOKEN || '',
// cache lifetime in seconds; the webhook busts it early
ttl: Number(process.env.CONTENT_TTL || 600),
// shared secret for the gitea/github webhook
webhookSecret: process.env.CONTENT_WEBHOOK_SECRET || '',
// folder used when provider === 'local' (handy for writing offline)
localDir: process.env.CONTENT_LOCAL_DIR || 'content',
// portfolio data (bio, skills, links, projects), edited from /dash.
// relative to the repo root, not to CONTENT_DIR.
siteFile: process.env.CONTENT_SITE_FILE || 'site.json',
},
// --- projects: public repos, read from the forges ---------------------
projects: {
// account names; leave one empty to switch that source off
githubUser: process.env.PROJECTS_GITHUB_USER || 'nak0x',
giteaUser: process.env.PROJECTS_GITEA_USER || 'Nak0x',
// defaults to the gitea instance the content repo lives on
giteaUrl: process.env.PROJECTS_GITEA_URL || '',
// optional PATs — mostly to escape github's 60/h anonymous rate limit
githubToken: process.env.PROJECTS_GITHUB_TOKEN || '',
giteaToken: process.env.PROJECTS_GITEA_TOKEN || '',
// comma-separated; matches "name" or "owner/name"
exclude: process.env.PROJECTS_EXCLUDE || '',
// forks are somebody else's project
includeForks: process.env.PROJECTS_INCLUDE_FORKS === 'true',
ttl: Number(process.env.PROJECTS_TTL || 900),
},
// --- /dash (server only) ----------------------------------------------
dash: {
// unset => /dash is disabled entirely
password: process.env.DASH_PASSWORD || '',
// optional; defaults to a key derived from the password
sessionSecret: process.env.DASH_SESSION_SECRET || '',
// session lifetime in hours
sessionHours: Number(process.env.DASH_SESSION_HOURS || 168),
},
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://nak0x.dev',
},
},
app: {
head: {
htmlAttrs: { lang: 'en' },
link: [
{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
{ rel: 'alternate', type: 'application/rss+xml', title: 'nak0x.dev', href: '/rss.xml' },
],
script: [
{
// set the theme before first paint, no flash
innerHTML: `(function(){try{var s=localStorage.getItem("theme");var d=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;document.documentElement.setAttribute("data-theme",s||(d?"dark":"light"))}catch(e){}})()`,
tagPosition: 'head',
},
],
},
},
})

10089
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "nak0x-folio",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "node .output/server/index.mjs",
"generate": "nuxt generate",
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"markdown-it": "^15.0.1",
"markdown-it-anchor": "^10.0.0",
"nuxt": "^4.5.2",
"shiki": "^4.4.3",
"vue": "^3.5.42",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^26.5.0",
"typescript": "^5.9.2",
"vue-tsc": "^3.1.1"
}
}

5
public/favicon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#0e0e10"/>
<text x="32" y="44" font-family="ui-monospace, monospace" font-size="38" font-weight="700"
text-anchor="middle" fill="#e56fd8">n</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1,12 @@
import { getNeighbours, getPost } from '~~/server/utils/posts'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
const post = await getPost(slug)
if (!post) throw createError({ statusCode: 404, message: 'post not found' })
const neighbours = await getNeighbours(slug)
return { post, ...neighbours }
})

View File

@ -0,0 +1,16 @@
import { listPosts, listTags } from '~~/server/utils/posts'
export default defineEventHandler(async (event) => {
const { tag } = getQuery(event) as { tag?: string }
try {
const [posts, tags] = await Promise.all([listPosts({ tag }), listTags()])
return { posts, tags }
} catch (error) {
console.error('[api/blog] failed to list posts:', error)
throw createError({
statusCode: 502,
message: 'the content repository is unreachable',
})
}
})

View File

@ -0,0 +1,26 @@
import {
assertNotRateLimited,
clearFailures,
dashEnabled,
issueSession,
passwordMatches,
recordFailure,
} from '~~/server/utils/dash-auth'
export default defineEventHandler(async (event) => {
if (!dashEnabled()) {
throw createError({ statusCode: 503, message: 'DASH_PASSWORD is not configured' })
}
assertNotRateLimited(event)
const { password } = await readBody<{ password?: string }>(event)
if (!password || !passwordMatches(password)) {
recordFailure(event)
throw createError({ statusCode: 401, message: 'wrong password' })
}
clearFailures(event)
issueSession(event)
return { ok: true }
})

View File

@ -0,0 +1,6 @@
import { clearDashSession } from '~~/server/utils/dash-auth'
export default defineEventHandler((event) => {
clearDashSession(event)
return { ok: true }
})

View File

@ -0,0 +1,12 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { deletePost } from '~~/server/utils/post-editor'
export default defineEventHandler(async (event) => {
assertDashSession(event)
const slug = getRouterParam(event, 'slug')
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
const body = await readBody<{ message?: string }>(event).catch(() => null)
return await deletePost(slug, body?.message)
})

View File

@ -0,0 +1,14 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { getPostDraft } from '~~/server/utils/post-editor'
export default defineEventHandler(async (event) => {
assertDashSession(event)
const slug = getRouterParam(event, 'slug')
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
const draft = await getPostDraft(slug)
if (!draft) throw createError({ statusCode: 404, message: 'post not found' })
return draft
})

View File

@ -0,0 +1,13 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { updatePost } from '~~/server/utils/post-editor'
import type { SavePayload } from '#shared/types/dash'
export default defineEventHandler(async (event) => {
assertDashSession(event)
const slug = getRouterParam(event, 'slug')
if (!slug) throw createError({ statusCode: 400, message: 'missing slug' })
const payload = await readBody<SavePayload>(event)
return await updatePost(slug, payload)
})

View File

@ -0,0 +1,12 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { getIndex } from '~~/server/utils/posts'
/** every post, drafts included, with the sha the editor needs */
export default defineEventHandler(async (event) => {
assertDashSession(event)
const index = await getIndex()
return {
posts: index.map((entry) => ({ ...entry.meta, sha: entry.sha })),
}
})

View File

@ -0,0 +1,9 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { createPost } from '~~/server/utils/post-editor'
import type { SavePayload } from '#shared/types/dash'
export default defineEventHandler(async (event) => {
assertDashSession(event)
const payload = await readBody<SavePayload>(event)
return await createPost(payload)
})

View File

@ -0,0 +1,21 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { renderMarkdown } from '~~/server/utils/markdown'
import { useContentProvider } from '~~/server/utils/providers'
/**
* The editor has no markdown renderer of its own — it posts the body here and
* gets back exactly the HTML the published page would show, Shiki included.
*/
export default defineEventHandler(async (event) => {
assertDashSession(event)
const { body = '', path = '' } = await readBody<{ body?: string; path?: string }>(event)
const provider = useContentProvider()
const baseDir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
const { html, toc, readingTime } = await renderMarkdown(body, {
resolveAsset: (src) => provider.assetUrl(baseDir ? `${baseDir}/${src}` : src),
})
return { html, toc, readingTime }
})

View File

@ -0,0 +1,15 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { bustCache } from '~~/server/utils/cache'
import { getIndex } from '~~/server/utils/posts'
import { getSiteDocument } from '~~/server/utils/site'
import { getProjects } from '~~/server/utils/repos'
/** drop everything cached from the content repo and read it again */
export default defineEventHandler(async (event) => {
assertDashSession(event)
const cleared = bustCache()
await Promise.all([getIndex(), getSiteDocument(), getProjects()])
return { ok: true, cleared }
})

View File

@ -0,0 +1,35 @@
import { dashEnabled, hasValidSession } from '~~/server/utils/dash-auth'
import { useContentProvider } from '~~/server/utils/providers'
import { contentConfig } from '~~/server/utils/config'
/** what the dashboard needs before it can render anything */
export default defineEventHandler((event) => {
const enabled = dashEnabled()
const authenticated = enabled && hasValidSession(event)
if (!authenticated) return { enabled, authenticated, writable: false }
const content = contentConfig()
let writable = false
let kind = content.provider
try {
const provider = useContentProvider()
writable = provider.writable
kind = provider.kind
} catch (error) {
console.error('[dash] provider unavailable:', error)
}
return {
enabled,
authenticated,
writable,
provider: kind,
repo: content.repo,
branch: content.branch,
dir: content.dir,
siteFile: content.siteFile,
ttl: content.ttl,
webhook: !!content.webhookSecret,
}
})

View File

@ -0,0 +1,7 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { getSiteDocument } from '~~/server/utils/site'
export default defineEventHandler(async (event) => {
assertDashSession(event)
return await getSiteDocument()
})

View File

@ -0,0 +1,25 @@
import { assertDashSession } from '~~/server/utils/dash-auth'
import { putSiteData } from '~~/server/utils/site'
import { useContentProvider } from '~~/server/utils/providers'
import { assertWritable } from '~~/server/utils/providers/types'
import type { SiteData } from '#shared/site'
export default defineEventHandler(async (event) => {
assertDashSession(event)
assertWritable(useContentProvider())
const { data, sha, message } = await readBody<{
data: SiteData
sha?: string | null
message?: string
}>(event)
if (!data || typeof data !== 'object') {
throw createError({ statusCode: 422, message: 'missing site data' })
}
return await putSiteData(data, {
sha,
message: message?.trim() || 'site: update portfolio content',
})
})

View File

@ -0,0 +1,6 @@
import { getProjects } from '~~/server/utils/repos'
export default defineEventHandler(async (event) => {
setHeader(event, 'cache-control', 'public, max-age=300')
return await getProjects()
})

6
server/api/site.get.ts Normal file
View File

@ -0,0 +1,6 @@
import { getSiteData } from '~~/server/utils/site'
export default defineEventHandler(async (event) => {
setHeader(event, 'cache-control', 'public, max-age=60')
return await getSiteData()
})

View File

@ -0,0 +1,58 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { bustCache } from '~~/server/utils/cache'
import { getIndex } from '~~/server/utils/posts'
/**
* Push webhook for the content repo.
*
* Gitea : Settings -> Webhooks -> Gitea, url = https://nak0x.dev/api/webhook/content,
* method POST, content type application/json, secret = CONTENT_WEBHOOK_SECRET.
* It signs the body into `X-Gitea-Signature` (hex hmac-sha256).
* GitHub : same idea, header is `X-Hub-Signature-256` and looks like `sha256=<hex>`.
*
* A valid call drops the cache and immediately warms the index back up, so the
* first visitor after a push does not pay for the refetch.
*/
export default defineEventHandler(async (event) => {
const secret = contentConfig().webhookSecret
if (!secret) {
throw createError({
statusCode: 503,
message: 'CONTENT_WEBHOOK_SECRET is not configured',
})
}
const raw = await readRawBody(event, false)
if (!raw) throw createError({ statusCode: 400, message: 'empty body' })
const body = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw))
const digest = createHmac('sha256', secret).update(body).digest('hex')
const provided =
getHeader(event, 'x-gitea-signature')
?? getHeader(event, 'x-hub-signature-256')?.replace(/^sha256=/, '')
?? ''
if (!safeEqual(digest, provided)) {
throw createError({ statusCode: 401, message: 'bad signature' })
}
const cleared = bustCache('posts:')
// warm the index again; failures here are not the webhook's problem
getIndex().catch((error) => console.error('[webhook] re-warm failed:', error))
const eventName =
getHeader(event, 'x-gitea-event') ?? getHeader(event, 'x-github-event') ?? 'unknown'
console.info(`[webhook] ${eventName}: cleared ${cleared} cache entries`)
return { ok: true, event: eventName, cleared }
})
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8')
const bufB = Buffer.from(b, 'utf8')
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}

View File

@ -0,0 +1,5 @@
export default defineEventHandler(() => ({
ok: true,
uptime: Math.round(process.uptime()),
provider: contentConfig().provider,
}))

View File

@ -0,0 +1,12 @@
export default defineEventHandler((event) => {
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
return [
'User-agent: *',
'Allow: /',
'Disallow: /dash',
'',
`Sitemap: ${base}/sitemap.xml`,
'',
].join('\n')
})

View File

@ -0,0 +1,44 @@
import { listPosts } from '~~/server/utils/posts'
import { getSiteData } from '~~/server/utils/site'
const escape = (s: string) =>
s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
export default defineEventHandler(async (event) => {
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
const posts = (await listPosts()).filter((p) => !p.draft).slice(0, 30)
const site = await getSiteData()
const items = posts
.map(
(post) => ` <item>
<title>${escape(post.title)}</title>
<link>${base}/blog/${post.slug}</link>
<guid isPermaLink="true">${base}/blog/${post.slug}</guid>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<description>${escape(post.description)}</description>
${post.tags.map((t) => ` <category>${escape(t)}</category>`).join('\n')}
</item>`,
)
.join('\n')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escape(site.domain)}</title>
<link>${base}</link>
<description>${escape(site.description)}</description>
<language>en</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${base}/rss.xml" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>`
setHeader(event, 'content-type', 'application/rss+xml; charset=utf-8')
setHeader(event, 'cache-control', 'public, max-age=600')
return xml
})

View File

@ -0,0 +1,21 @@
import { listPosts } from '~~/server/utils/posts'
export default defineEventHandler(async (event) => {
const base = useRuntimeConfig().public.siteUrl.replace(/\/+$/, '')
const posts = await listPosts()
const urls = [
{ loc: `${base}/`, lastmod: new Date().toISOString() },
{ loc: `${base}/projects`, lastmod: new Date().toISOString() },
{ loc: `${base}/blog`, lastmod: posts[0]?.date ?? new Date().toISOString() },
...posts.map((p) => ({ loc: `${base}/blog/${p.slug}`, lastmod: p.updated ?? p.date })),
]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) => ` <url><loc>${u.loc}</loc><lastmod>${u.lastmod}</lastmod></url>`).join('\n')}
</urlset>`
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
return xml
})

71
server/utils/cache.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* A tiny stale-while-revalidate cache.
*
* The blog lives in a git repo we do not own the request cycle of, so:
* - fresh entries are served straight away
* - stale entries are served immediately and refreshed in the background
* - if the refresh fails we keep serving the stale copy rather than 500ing
* - the webhook calls `bustCache()` so a push shows up without a redeploy
*/
interface Entry<T> {
value: T
storedAt: number
}
const store = new Map<string, Entry<unknown>>()
const inflight = new Map<string, Promise<unknown>>()
export async function cached<T>(key: string, ttlSeconds: number, load: () => Promise<T>): Promise<T> {
const ttl = Math.max(0, ttlSeconds) * 1000
const hit = store.get(key) as Entry<T> | undefined
const isFresh = hit && Date.now() - hit.storedAt < ttl
if (hit && isFresh) return hit.value
const refresh = () => {
const running = inflight.get(key) as Promise<T> | undefined
if (running) return running
const p = load()
.then((value) => {
store.set(key, { value, storedAt: Date.now() })
return value
})
.finally(() => inflight.delete(key))
inflight.set(key, p)
return p
}
// nothing cached yet — we have to wait
if (!hit) return refresh()
// stale: hand back the old copy, warm the new one behind it
refresh().catch((error) => {
console.error(`[cache] background refresh of "${key}" failed:`, error)
})
return hit.value
}
export function bustCache(prefix?: string): number {
if (!prefix) {
const n = store.size
store.clear()
return n
}
let n = 0
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
store.delete(key)
n++
}
}
return n
}
export function cacheKeys(): string[] {
return [...store.keys()]
}

92
server/utils/config.ts Normal file
View File

@ -0,0 +1,92 @@
/**
* Nuxt only overrides runtimeConfig from `NUXT_`-prefixed variables, and the
* `process.env.X` fallbacks written in `nuxt.config.ts` are frozen at build
* time. That is a bad fit for Coolify, where you set plain `CONTENT_*` vars on
* an already-built image — so read them here, at request time, and fall back to
* whatever the build baked in.
*/
export interface ContentConfig {
provider: string
repo: string
branch: string
dir: string
giteaUrl: string
token: string
ttl: number
webhookSecret: string
localDir: string
siteFile: string
}
export interface ProjectsConfig {
githubUser: string
giteaUser: string
giteaUrl: string
githubToken: string
giteaToken: string
exclude: string[]
includeForks: boolean
ttl: number
}
export interface DashConfig {
password: string
sessionSecret: string
sessionHours: number
}
export function contentConfig(): ContentConfig {
const baked = useRuntimeConfig().content
const env = process.env
return {
provider: env.CONTENT_PROVIDER || baked.provider,
repo: env.CONTENT_REPO || baked.repo,
branch: env.CONTENT_BRANCH || baked.branch,
dir: env.CONTENT_DIR ?? baked.dir,
giteaUrl: env.GITEA_URL || baked.giteaUrl,
token: env.CONTENT_TOKEN || baked.token,
ttl: Number(env.CONTENT_TTL || baked.ttl) || 600,
webhookSecret: env.CONTENT_WEBHOOK_SECRET || baked.webhookSecret,
localDir: env.CONTENT_LOCAL_DIR || baked.localDir,
siteFile: env.CONTENT_SITE_FILE || baked.siteFile,
}
}
export function projectsConfig(): ProjectsConfig {
const baked = useRuntimeConfig().projects
const env = process.env
const content = contentConfig()
return {
githubUser: env.PROJECTS_GITHUB_USER ?? baked.githubUser,
giteaUser: env.PROJECTS_GITEA_USER ?? baked.giteaUser,
// no dedicated instance configured => reuse the content repo's
giteaUrl: env.PROJECTS_GITEA_URL || baked.giteaUrl || content.giteaUrl,
githubToken: env.PROJECTS_GITHUB_TOKEN || baked.githubToken,
// the content token only helps here if it belongs to the same instance
giteaToken:
env.PROJECTS_GITEA_TOKEN ||
baked.giteaToken ||
(content.provider === 'gitea' ? content.token : ''),
exclude: (env.PROJECTS_EXCLUDE ?? baked.exclude)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
includeForks: env.PROJECTS_INCLUDE_FORKS
? env.PROJECTS_INCLUDE_FORKS === 'true'
: baked.includeForks,
ttl: Number(env.PROJECTS_TTL || baked.ttl) || 900,
}
}
export function dashConfig(): DashConfig {
const baked = useRuntimeConfig().dash
const env = process.env
return {
password: env.DASH_PASSWORD || baked.password,
sessionSecret: env.DASH_SESSION_SECRET || baked.sessionSecret,
sessionHours: Number(env.DASH_SESSION_HOURS || baked.sessionHours) || 168,
}
}

123
server/utils/dash-auth.ts Normal file
View File

@ -0,0 +1,123 @@
import { createHmac, createHash, timingSafeEqual } from 'node:crypto'
import type { H3Event } from 'h3'
import { dashConfig } from './config'
const COOKIE = 'dash_session'
/** brute-force brake: per-IP, in memory, resets on restart */
const attempts = new Map<string, { count: number; until: number }>()
const MAX_ATTEMPTS = 8
const WINDOW_MS = 10 * 60 * 1000
export function dashEnabled(): boolean {
return !!dashConfig().password
}
/**
* Signing key. Deriving it from the password by default means one less variable
* to set — and changing the password invalidates every open session, which is
* the behaviour you want anyway.
*/
function sessionKey(): string {
const { password, sessionSecret } = dashConfig()
if (sessionSecret) return sessionSecret
return createHash('sha256').update(`${password}:dash-session`).digest('hex')
}
function sign(payload: string): string {
return createHmac('sha256', sessionKey()).update(payload).digest('hex')
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8')
const bufB = Buffer.from(b, 'utf8')
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
function clientIp(event: H3Event): string {
return (
getRequestHeader(event, 'cf-connecting-ip')
?? getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
?? event.node.req.socket.remoteAddress
?? 'unknown'
)
}
function isHttps(event: H3Event): boolean {
return getRequestHeader(event, 'x-forwarded-proto')?.split(',')[0]?.trim() === 'https'
}
export function assertNotRateLimited(event: H3Event): void {
const ip = clientIp(event)
const entry = attempts.get(ip)
if (entry && entry.count >= MAX_ATTEMPTS && Date.now() < entry.until) {
const seconds = Math.ceil((entry.until - Date.now()) / 1000)
throw createError({
statusCode: 429,
message: `too many attempts, wait ${seconds}s`,
})
}
}
export function recordFailure(event: H3Event): void {
const ip = clientIp(event)
const now = Date.now()
const entry = attempts.get(ip)
if (!entry || now >= entry.until) attempts.set(ip, { count: 1, until: now + WINDOW_MS })
else entry.count++
}
export function clearFailures(event: H3Event): void {
attempts.delete(clientIp(event))
}
/** compare hashes, so the timing does not leak the password's length */
export function passwordMatches(candidate: string): boolean {
const { password } = dashConfig()
if (!password) return false
const a = createHash('sha256').update(candidate).digest('hex')
const b = createHash('sha256').update(password).digest('hex')
return safeEqual(a, b)
}
export function issueSession(event: H3Event): void {
const hours = dashConfig().sessionHours
const expires = Date.now() + hours * 3600 * 1000
const payload = String(expires)
setCookie(event, COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
sameSite: 'lax',
secure: isHttps(event),
path: '/',
maxAge: hours * 3600,
})
}
export function clearDashSession(event: H3Event): void {
deleteCookie(event, COOKIE, { path: '/' })
}
export function hasValidSession(event: H3Event): boolean {
if (!dashEnabled()) return false
const raw = getCookie(event, COOKIE)
if (!raw) return false
const [payload, signature] = raw.split('.')
if (!payload || !signature) return false
if (!safeEqual(sign(payload), signature)) return false
const expires = Number(payload)
return Number.isFinite(expires) && Date.now() < expires
}
export function assertDashSession(event: H3Event): void {
if (!dashEnabled()) {
throw createError({ statusCode: 503, message: 'DASH_PASSWORD is not configured' })
}
if (!hasValidSession(event)) {
throw createError({ statusCode: 401, message: 'not signed in' })
}
}

113
server/utils/markdown.ts Normal file
View File

@ -0,0 +1,113 @@
import MarkdownIt from 'markdown-it'
import anchor from 'markdown-it-anchor'
import GithubSlugger from 'github-slugger'
import { createHighlighter, type Highlighter } from 'shiki'
import type { TocEntry } from '#shared/types/blog'
/**
* Languages compiled into the image. Shiki's `codeToHtml` is async but
* markdown-it's fence rule is not, so we preload a fixed set at boot and fall
* back to plain text for anything else.
*/
const LANGS = [
'rust', 'typescript', 'javascript', 'vue', 'python', 'bash', 'shell',
'json', 'yaml', 'toml', 'html', 'css', 'c', 'cpp', 'csharp', 'go',
'sql', 'markdown', 'diff', 'docker', 'nginx', 'lua', 'glsl', 'ini',
] as const
const THEMES = { light: 'github-light', dark: 'github-dark' } as const
let highlighterPromise: Promise<Highlighter> | null = null
function useHighlighter(): Promise<Highlighter> {
highlighterPromise ??= createHighlighter({
themes: [THEMES.light, THEMES.dark],
langs: [...LANGS],
})
return highlighterPromise
}
export interface RenderResult {
html: string
toc: TocEntry[]
/** minutes, rounded up, floor of 1 */
readingTime: number
}
export async function renderMarkdown(
source: string,
options: { resolveAsset?: (src: string) => string } = {},
): Promise<RenderResult> {
const highlighter = await useHighlighter()
const loaded = new Set(highlighter.getLoadedLanguages())
const resolveAsset = options.resolveAsset ?? ((s: string) => s)
// one slugger per render so duplicate headings get -1, -2 … suffixes
const slugger = new GithubSlugger()
const toc: TocEntry[] = []
const md = new MarkdownIt({
html: true,
linkify: true,
breaks: false,
highlight(code, lang) {
const language = loaded.has(lang) ? lang : 'text'
try {
return highlighter.codeToHtml(code, { lang: language, themes: THEMES })
} catch {
return '' // let markdown-it escape it itself
}
},
})
md.use(anchor, {
slugify: (s: string) => slugger.slug(s),
level: [2, 3],
permalink: anchor.permalink.linkInsideHeader({
symbol: '#',
class: 'heading-anchor',
placement: 'after',
ariaHidden: true,
}),
callback(token, info) {
// `token.tag` is h2 / h3 — deeper levels are filtered out above
toc.push({ id: info.slug, text: info.title, depth: Number(token.tag.slice(1)) })
},
})
// external links open in a new tab
const renderToken = (tokens: any, i: number, opts: any, _env: any, self: any) =>
self.renderToken(tokens, i, opts)
const defaultLink = md.renderer.rules.link_open ?? renderToken
md.renderer.rules.link_open = (tokens, i, opts, env, self) => {
const token = tokens[i]
const href = String(token?.attrGet('href') ?? '')
if (token && /^https?:\/\//i.test(href)) {
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
}
return defaultLink(tokens, i, opts, env, self)
}
// relative images resolve against the content repo
const defaultImage = md.renderer.rules.image ?? renderToken
md.renderer.rules.image = (tokens, i, opts, env, self) => {
const token = tokens[i]
if (token) {
const src = String(token.attrGet('src') ?? '')
if (src && !/^(https?:)?\/\//i.test(src) && !src.startsWith('data:')) {
token.attrSet('src', resolveAsset(src))
}
token.attrSet('loading', 'lazy')
}
return defaultImage(tokens, i, opts, env, self)
}
const html = md.render(source)
const words = source.split(/\s+/).filter(Boolean).length
const readingTime = Math.max(1, Math.ceil(words / 200))
return { html, toc, readingTime }
}

181
server/utils/post-editor.ts Normal file
View File

@ -0,0 +1,181 @@
import matter from 'gray-matter'
import type { PostDraft, PostFrontmatterInput, SavePayload } from '#shared/types/dash'
import { useContentProvider } from './providers'
import { assertWritable } from './providers/types'
import { contentConfig } from './config'
import { bustCache } from './cache'
import { findEntry, getIndex, slugify } from './posts'
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const today = () => new Date().toISOString().slice(0, 10)
function asStringList(value: unknown): string[] {
if (Array.isArray(value)) return value.map((v) => String(v).trim().toLowerCase()).filter(Boolean)
if (typeof value === 'string') {
return value.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean)
}
return []
}
function asDate(value: unknown, fallback: string): string {
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
return value.toISOString().slice(0, 10)
}
const text = String(value ?? '').trim()
if (DATE_RE.test(text)) return text
if (text) {
const parsed = new Date(text)
if (!Number.isNaN(parsed.valueOf())) return parsed.toISOString().slice(0, 10)
}
return fallback
}
/** what the editor sends -> what actually goes in the file */
function cleanFrontmatter(
input: Partial<PostFrontmatterInput>,
fallbackSlug: string,
): PostFrontmatterInput {
const title = String(input.title ?? '').trim()
if (!title) {
throw createError({ statusCode: 422, message: 'a post needs a title' })
}
const slug = slugify(String(input.slug ?? '').trim() || title) || fallbackSlug
if (!slug) {
throw createError({ statusCode: 422, message: 'could not derive a slug from that title' })
}
return {
title,
description: String(input.description ?? '').trim(),
date: asDate(input.date, today()),
updated: input.updated ? asDate(input.updated, '') : '',
tags: asStringList(input.tags),
slug,
draft: input.draft === true,
}
}
/** drop empty fields so the file stays readable */
function serialize(frontmatter: PostFrontmatterInput, body: string): string {
const data: Record<string, unknown> = {
title: frontmatter.title,
date: frontmatter.date,
slug: frontmatter.slug,
}
if (frontmatter.description) data.description = frontmatter.description
if (frontmatter.updated) data.updated = frontmatter.updated
if (frontmatter.tags.length) data.tags = frontmatter.tags
if (frontmatter.draft) data.draft = true
return matter.stringify(`${body.trimEnd()}\n`, data)
}
export async function getPostDraft(slug: string): Promise<PostDraft | null> {
const entry = await findEntry(slug)
if (!entry) return null
const provider = useContentProvider()
const raw = await provider.read(entry.path)
const { data, content } = matter(raw)
return {
path: entry.path,
sha: entry.sha,
sourceUrl: provider.sourceUrl(entry.path),
frontmatter: {
title: String(data.title ?? entry.meta.title),
description: String(data.description ?? data.summary ?? ''),
date: asDate(data.date, entry.meta.date.slice(0, 10)),
updated: data.updated ? asDate(data.updated, '') : '',
tags: asStringList(data.tags),
slug: entry.meta.slug,
draft: data.draft === true || data.published === false,
},
body: content.replace(/^\n+/, ''),
}
}
export async function createPost(payload: SavePayload) {
const provider = useContentProvider()
assertWritable(provider)
const frontmatter = cleanFrontmatter(payload.frontmatter, '')
const dir = contentConfig().dir.replace(/^\/+|\/+$/g, '')
const path = `${dir ? `${dir}/` : ''}${frontmatter.date}-${frontmatter.slug}.md`
const index = await getIndex()
if (index.some((e) => e.meta.slug === frontmatter.slug)) {
throw createError({ statusCode: 409, message: `a post already uses the slug "${frontmatter.slug}"` })
}
if (await provider.stat(path)) {
throw createError({ statusCode: 409, message: `${path} already exists in the repo` })
}
const result = await provider.write({
path,
content: serialize(frontmatter, payload.body ?? ''),
message: payload.message?.trim() || `post: add ${frontmatter.slug}`,
})
bustCache('posts:')
return { slug: frontmatter.slug, path, sha: result.sha, commit: result.commit }
}
export async function updatePost(slug: string, payload: SavePayload) {
const provider = useContentProvider()
assertWritable(provider)
const entry = await findEntry(slug)
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
const frontmatter = cleanFrontmatter(payload.frontmatter, slug)
// the file keeps its path even when the slug changes — the `slug:` field is
// what drives the url, so renaming never orphans a commit history.
const live = await provider.stat(entry.path)
if (payload.sha && live?.sha && payload.sha !== live.sha) {
throw createError({
statusCode: 409,
message: 'this post changed in the repo since you opened it — reload and reapply',
})
}
if (frontmatter.slug !== slug) {
const clash = (await getIndex()).find(
(e) => e.meta.slug === frontmatter.slug && e.path !== entry.path,
)
if (clash) {
throw createError({ statusCode: 409, message: `the slug "${frontmatter.slug}" is taken` })
}
}
const result = await provider.write({
path: entry.path,
content: serialize(frontmatter, payload.body ?? ''),
message: payload.message?.trim() || `post: update ${frontmatter.slug}`,
sha: live?.sha ?? entry.sha,
})
bustCache('posts:')
return { slug: frontmatter.slug, path: entry.path, sha: result.sha, commit: result.commit }
}
export async function deletePost(slug: string, message?: string) {
const provider = useContentProvider()
assertWritable(provider)
const entry = await findEntry(slug)
if (!entry) throw createError({ statusCode: 404, message: 'post not found' })
const live = await provider.stat(entry.path)
await provider.remove({
path: entry.path,
message: message?.trim() || `post: remove ${slug}`,
sha: live?.sha ?? entry.sha,
})
bustCache('posts:')
return { slug, path: entry.path }
}

190
server/utils/posts.ts Normal file
View File

@ -0,0 +1,190 @@
import matter from 'gray-matter'
import { dirname, join, normalize } from 'node:path/posix'
import type { Post, PostMeta } from '#shared/types/blog'
import { useContentProvider } from './providers'
import { renderMarkdown } from './markdown'
import { cached } from './cache'
const INDEX_KEY = 'posts:index'
const postKey = (slug: string) => `posts:post:${slug}`
interface IndexEntry {
meta: PostMeta
path: string
/** blob sha, for optimistic concurrency when editing from /dash */
sha: string
}
function ttl(): number {
return contentConfig().ttl
}
function showDrafts(): boolean {
return process.env.SHOW_DRAFTS === 'true' || import.meta.dev
}
export function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/** `2026-09-08-hello-world.md` -> `hello-world` */
function slugFromPath(path: string): string {
const base = path.split('/').pop() ?? path
return slugify(base.replace(/\.mdx?$/i, '').replace(/^\d{4}-\d{2}-\d{2}[-_]/, ''))
}
function dateFromPath(path: string): string | null {
const match = /(\d{4}-\d{2}-\d{2})/.exec(path.split('/').pop() ?? '')
return match?.[1] ?? null
}
function toIso(value: unknown, fallback: string): string {
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString()
if (typeof value === 'string' || typeof value === 'number') {
const d = new Date(value)
if (!Number.isNaN(d.valueOf())) return d.toISOString()
}
return fallback
}
function toTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim().toLowerCase()).filter(Boolean)
if (typeof value === 'string') {
return value.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
}
return []
}
/** first paragraph of the body, for posts without a `description:` */
function excerpt(body: string, max = 180): string {
const text = body
.replace(/^#{1,6}\s+.*$/gm, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/[*_`>#-]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (text.length <= max) return text
return `${text.slice(0, max).replace(/\s+\S*$/, '')}…`
}
function parse(path: string, raw: string): { meta: PostMeta; body: string } {
const provider = useContentProvider()
const { data, content } = matter(raw)
const fallbackDate = dateFromPath(path) ?? '1970-01-01'
const words = content.split(/\s+/).filter(Boolean).length
const meta: PostMeta = {
slug: String(data.slug || slugFromPath(path)),
title: String(data.title || slugFromPath(path).replace(/-/g, ' ')),
description: String(data.description || data.summary || excerpt(content)),
date: toIso(data.date, new Date(fallbackDate).toISOString()),
updated: data.updated ? toIso(data.updated, '') || undefined : undefined,
tags: toTags(data.tags),
draft: data.draft === true || data.published === false,
readingTime: Math.max(1, Math.ceil(words / 200)),
path,
sourceUrl: provider.sourceUrl(path),
}
return { meta, body: content }
}
async function loadIndex(): Promise<IndexEntry[]> {
const provider = useContentProvider()
const files = await provider.list()
const entries = await Promise.all(
files.map(async (file) => {
try {
const raw = await provider.read(file.path)
const { meta } = parse(file.path, raw)
return { meta, path: meta.path, sha: file.sha ?? '' }
} catch (error) {
console.error(`[posts] could not read ${file.path}:`, error)
return null
}
}),
)
return entries
.filter((e): e is IndexEntry => e !== null)
.sort((a, b) => b.meta.date.localeCompare(a.meta.date))
}
export async function getIndex(): Promise<IndexEntry[]> {
return cached(INDEX_KEY, ttl(), loadIndex)
}
/** the index row for a slug — path and sha included, for the editor */
export async function findEntry(slug: string): Promise<IndexEntry | null> {
return (await getIndex()).find((e) => e.meta.slug === slug) ?? null
}
export { parse as parsePost }
export type { IndexEntry }
export async function listPosts(
options: { tag?: string; includeDrafts?: boolean } = {},
): Promise<PostMeta[]> {
const index = await getIndex()
const drafts = options.includeDrafts ?? showDrafts()
return index
.map((e) => e.meta)
.filter((m) => drafts || !m.draft)
.filter((m) => !options.tag || m.tags.includes(options.tag.toLowerCase()))
}
export async function listTags(): Promise<Array<{ tag: string; count: number }>> {
const posts = await listPosts()
const counts = new Map<string, number>()
for (const post of posts) {
for (const tag of post.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
return [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}
export async function getPost(slug: string): Promise<Post | null> {
const index = await getIndex()
const entry = index.find((e) => e.meta.slug === slug)
if (!entry) return null
if (entry.meta.draft && !showDrafts()) return null
return cached(postKey(slug), ttl(), async () => {
const provider = useContentProvider()
const raw = await provider.read(entry.path)
const { meta, body } = parse(entry.path, raw)
const baseDir = dirname(entry.path)
const { html, toc, readingTime } = await renderMarkdown(body, {
resolveAsset: (src) => provider.assetUrl(normalize(join(baseDir, src))),
})
return { ...meta, html, toc, readingTime }
})
}
/** previous / next in reverse-chronological order */
export async function getNeighbours(slug: string): Promise<{
previous: PostMeta | null
next: PostMeta | null
}> {
const posts = await listPosts()
const i = posts.findIndex((p) => p.slug === slug)
if (i === -1) return { previous: null, next: null }
return {
previous: posts[i + 1] ?? null, // older
next: posts[i - 1] ?? null, // newer
}
}

View File

@ -0,0 +1,113 @@
import type {
ContentProvider,
ProviderOptions,
RemoteFile,
WriteOptions,
WriteResult,
} from './types'
import { isMarkdown, isNotFound, normalizeDir, splitRepo, toBase64 } from './types'
interface GiteaTree {
tree?: Array<{ path: string; type: 'blob' | 'tree'; sha: string }>
truncated?: boolean
}
interface GiteaContents {
sha: string
content?: string
}
interface GiteaFileResponse {
content?: GiteaContents | null
commit?: { sha?: string }
}
export function createGiteaProvider(opts: ProviderOptions): ContentProvider {
const { owner, name } = splitRepo(opts.repo)
const base = opts.giteaUrl.replace(/\/+$/, '')
const api = `${base}/api/v1/repos/${owner}/${name}`
const dir = normalizeDir(opts.dir)
const headers: Record<string, string> = { Accept: 'application/json' }
if (opts.token) headers.Authorization = `token ${opts.token}`
return {
kind: 'gitea',
writable: !!opts.token,
async list(): Promise<RemoteFile[]> {
const tree = await $fetch<GiteaTree>(
`${api}/git/trees/${encodeURIComponent(opts.branch)}`,
{ headers, query: { recursive: 'true', per_page: 1000 } },
)
if (tree.truncated) {
console.warn('[gitea] tree response was truncated — some posts may be missing')
}
return (tree.tree ?? [])
.filter((e) => e.type === 'blob' && isMarkdown(e.path))
.filter((e) => (dir ? e.path.startsWith(`${dir}/`) : true))
.map((e) => ({ path: e.path, sha: e.sha }))
},
async read(path: string): Promise<string> {
// the raw endpoint returns the file verbatim and honours the token
return await $fetch<string>(`${api}/raw/${encodeURI(path)}`, {
headers: opts.token ? { Authorization: `token ${opts.token}` } : {},
query: { ref: opts.branch },
responseType: 'text',
})
},
async stat(path: string) {
try {
const file = await $fetch<GiteaContents>(`${api}/contents/${encodeURI(path)}`, {
headers,
query: { ref: opts.branch },
})
return file?.sha ? { sha: file.sha } : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
},
async write({ path, content, message, sha }: WriteOptions): Promise<WriteResult> {
const body: Record<string, unknown> = {
content: toBase64(content),
message,
branch: opts.branch,
}
if (sha) body.sha = sha
const res = await $fetch<GiteaFileResponse>(`${api}/contents/${encodeURI(path)}`, {
method: sha ? 'PUT' : 'POST',
headers,
body,
})
return {
path,
sha: res?.content?.sha ?? '',
commit: res?.commit?.sha,
}
},
async remove({ path, message, sha }) {
await $fetch(`${api}/contents/${encodeURI(path)}`, {
method: 'DELETE',
headers,
body: { message, sha, branch: opts.branch },
})
},
sourceUrl(path: string): string {
return `${base}/${owner}/${name}/src/branch/${opts.branch}/${encodeURI(path)}`
},
assetUrl(path: string): string {
return `${base}/${owner}/${name}/raw/branch/${opts.branch}/${encodeURI(path)}`
},
}
}

View File

@ -0,0 +1,110 @@
import type {
ContentProvider,
ProviderOptions,
RemoteFile,
WriteOptions,
WriteResult,
} from './types'
import { isMarkdown, isNotFound, normalizeDir, splitRepo, toBase64 } from './types'
interface GithubTree {
tree?: Array<{ path: string; type: 'blob' | 'tree'; sha: string }>
truncated?: boolean
}
interface GithubFileResponse {
content?: { sha?: string } | null
commit?: { sha?: string }
}
export function createGithubProvider(opts: ProviderOptions): ContentProvider {
const { owner, name } = splitRepo(opts.repo)
const api = `https://api.github.com/repos/${owner}/${name}`
const dir = normalizeDir(opts.dir)
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'nak0x.dev',
}
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
return {
kind: 'github',
writable: !!opts.token,
async list(): Promise<RemoteFile[]> {
const tree = await $fetch<GithubTree>(
`${api}/git/trees/${encodeURIComponent(opts.branch)}`,
{ headers, query: { recursive: '1' } },
)
if (tree.truncated) {
console.warn('[github] tree response was truncated — some posts may be missing')
}
return (tree.tree ?? [])
.filter((e) => e.type === 'blob' && isMarkdown(e.path))
.filter((e) => (dir ? e.path.startsWith(`${dir}/`) : true))
.map((e) => ({ path: e.path, sha: e.sha }))
},
async read(path: string): Promise<string> {
return await $fetch<string>(`${api}/contents/${encodeURI(path)}`, {
headers: { ...headers, Accept: 'application/vnd.github.raw' },
query: { ref: opts.branch },
responseType: 'text',
})
},
async stat(path: string) {
try {
const file = await $fetch<{ sha?: string }>(`${api}/contents/${encodeURI(path)}`, {
headers,
query: { ref: opts.branch },
})
return file?.sha ? { sha: file.sha } : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
},
async write({ path, content, message, sha }: WriteOptions): Promise<WriteResult> {
const body: Record<string, unknown> = {
content: toBase64(content),
message,
branch: opts.branch,
}
if (sha) body.sha = sha
const res = await $fetch<GithubFileResponse>(`${api}/contents/${encodeURI(path)}`, {
method: 'PUT',
headers,
body,
})
return {
path,
sha: res?.content?.sha ?? '',
commit: res?.commit?.sha,
}
},
async remove({ path, message, sha }) {
await $fetch(`${api}/contents/${encodeURI(path)}`, {
method: 'DELETE',
headers,
body: { message, sha, branch: opts.branch },
})
},
sourceUrl(path: string): string {
return `https://github.com/${owner}/${name}/blob/${opts.branch}/${encodeURI(path)}`
},
assetUrl(path: string): string {
return `https://raw.githubusercontent.com/${owner}/${name}/${opts.branch}/${encodeURI(path)}`
},
}
}

View File

@ -0,0 +1,42 @@
import type { ContentProvider, ProviderOptions } from './types'
import { createGiteaProvider } from './gitea'
import { createGithubProvider } from './github'
import { createLocalProvider } from './local'
let cachedProvider: ContentProvider | null = null
export function useContentProvider(): ContentProvider {
if (cachedProvider) return cachedProvider
const content = contentConfig()
const opts: ProviderOptions = {
repo: content.repo,
branch: content.branch,
dir: content.dir,
token: content.token,
giteaUrl: content.giteaUrl,
localDir: content.localDir,
}
switch (content.provider) {
case 'github':
cachedProvider = createGithubProvider(opts)
break
case 'local':
cachedProvider = createLocalProvider(opts)
break
case 'gitea':
cachedProvider = createGiteaProvider(opts)
break
default:
throw new Error(
`unknown CONTENT_PROVIDER "${content.provider}" — expected gitea, github or local`,
)
}
return cachedProvider
}
export function resetContentProvider(): void {
cachedProvider = null
}

View File

@ -0,0 +1,88 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import type { ContentProvider, ProviderOptions, RemoteFile, WriteOptions, WriteResult } from './types'
import { isMarkdown, normalizeDir } from './types'
/**
* Reads and writes posts on the local filesystem. Useful for drafting before
* the repo exists, and for CI where you do not want to hit the network.
*
* There is no git here, so "sha" is just a content hash — enough to give the
* dashboard the same optimistic-concurrency guarantee as the git backends.
*/
export function createLocalProvider(opts: ProviderOptions): ContentProvider {
// `localDir` stands in for the repo root, so paths look exactly like the git
// backends': `posts/foo.md`, `site.json`.
const root = resolve(process.cwd(), opts.localDir)
const dir = normalizeDir(opts.dir)
const hash = (content: string) => createHash('sha256').update(content).digest('hex').slice(0, 40)
function safeResolve(path: string): string {
const full = resolve(root, path)
if (full !== root && !full.startsWith(root + sep)) {
throw new Error(`refusing to touch a path outside ${root}`)
}
return full
}
async function walk(current: string): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true }).catch(() => [])
const out: string[] = []
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.isDirectory()) out.push(...(await walk(full)))
else if (isMarkdown(entry.name)) out.push(full)
}
return out
}
const toRepoPath = (full: string) => relative(root, full).split(sep).join('/')
return {
kind: 'local',
writable: true,
async list(): Promise<RemoteFile[]> {
const files = await walk(dir ? join(root, dir) : root)
return await Promise.all(
files.map(async (full) => ({
path: toRepoPath(full),
sha: hash(await readFile(full, 'utf8')),
})),
)
},
async read(path: string): Promise<string> {
return await readFile(safeResolve(path), 'utf8')
},
async stat(path: string) {
try {
return { sha: hash(await readFile(safeResolve(path), 'utf8')) }
} catch {
return null
}
},
async write({ path, content }: WriteOptions): Promise<WriteResult> {
const full = safeResolve(path)
await mkdir(dirname(full), { recursive: true })
await writeFile(full, content, 'utf8')
return { path, sha: hash(content) }
},
async remove({ path }) {
await rm(safeResolve(path), { force: true })
},
sourceUrl(path: string): string {
return `file://${join(root, path)}`
},
assetUrl(path: string): string {
return `/${path}`
},
}
}

View File

@ -0,0 +1,86 @@
export interface RemoteFile {
/** path relative to the repo root */
path: string
sha?: string
}
export interface WriteOptions {
path: string
content: string
message: string
/** blob sha of the version you are replacing; omit to create */
sha?: string
}
export interface WriteResult {
path: string
/** blob sha of the version that now exists */
sha: string
/** commit sha, when the backend reports one */
commit?: string
}
export interface ContentProvider {
readonly kind: string
/** false when no token is configured — /dash stays read-only */
readonly writable: boolean
/** every markdown file under the configured directory */
list(): Promise<RemoteFile[]>
/** raw utf-8 contents of one file */
read(path: string): Promise<string>
/** blob sha of one file, or null when it does not exist */
stat(path: string): Promise<{ sha: string } | null>
/** create (no sha) or replace (with sha) a file, as one commit */
write(options: WriteOptions): Promise<WriteResult>
/** delete a file, as one commit */
remove(options: { path: string; message: string; sha: string }): Promise<void>
/** human-facing url of the file, for a "view source" link */
sourceUrl(path: string): string
/** url that serves a raw asset, used to rewrite relative images */
assetUrl(path: string): string
}
export interface ProviderOptions {
repo: string
branch: string
dir: string
token: string
giteaUrl: string
localDir: string
}
export function splitRepo(repo: string): { owner: string; name: string } {
const [owner, name] = repo.split('/')
if (!owner || !name) {
throw new Error(`CONTENT_REPO must look like "owner/repo", got "${repo}"`)
}
return { owner, name }
}
/** posts/foo/bar.md + dir "posts" -> "foo/bar" */
export function normalizeDir(dir: string): string {
return dir.replace(/^\/+|\/+$/g, '')
}
export function isMarkdown(path: string): boolean {
return /\.mdx?$/i.test(path)
}
export function toBase64(content: string): string {
return Buffer.from(content, 'utf8').toString('base64')
}
/** the provider needs a token to commit; say so with a useful message */
export function assertWritable(provider: ContentProvider): void {
if (!provider.writable) {
throw createError({
statusCode: 503,
message: `the ${provider.kind} provider has no write token — set CONTENT_TOKEN`,
})
}
}
/** $fetch throws on 404; treat that as "absent" rather than an error */
export function isNotFound(error: unknown): boolean {
return (error as { statusCode?: number })?.statusCode === 404
}

271
server/utils/repos.ts Normal file
View File

@ -0,0 +1,271 @@
/**
* The project list is the public repositories of the configured github and
* gitea accounts — not a list anybody maintains by hand.
*
* `site.json` still has a say: an entry there whose `href` or `name` matches a
* repository *enriches* it (bullets, a nicer name, a status, the featured
* flag). Entries that match nothing are kept as `manual` projects, so a thing
* with no repo behind it can still be shown.
*
* Both forges are optional and independent: one being down or rate-limited
* means the other's repos still render, and the failure is reported instead of
* swallowed.
*/
import type { Project, ProjectStatus } from '#shared/site'
import type {
ProjectSourceStatus,
ProjectsPayload,
RepoProject,
} from '#shared/types/projects'
import { cached } from './cache'
import { projectsConfig, type ProjectsConfig } from './config'
import { getSiteData } from './site'
const PROJECTS_KEY = 'projects:list'
/** a repo pushed to within this many days counts as alive */
const ACTIVE_DAYS = 120
/** the subset of the forge payloads we actually use — both speak it */
interface ForgeRepo {
name?: string
full_name?: string
description?: string | null
html_url?: string
homepage?: string | null
website?: string | null
language?: string | null
topics?: string[] | null
fork?: boolean
archived?: boolean
private?: boolean
stargazers_count?: number
stars_count?: number
created_at?: string
pushed_at?: string
updated_at?: string
}
// --- forges ----------------------------------------------------------------
async function fetchGithubRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'nak0x.dev',
}
if (cfg.githubToken) headers.Authorization = `Bearer ${cfg.githubToken}`
const out: ForgeRepo[] = []
for (let page = 1; page <= 10; page++) {
const batch = await $fetch<ForgeRepo[]>(
`https://api.github.com/users/${encodeURIComponent(cfg.githubUser)}/repos`,
{ headers, query: { per_page: 100, page, type: 'owner', sort: 'pushed' } },
)
if (!Array.isArray(batch) || !batch.length) break
out.push(...batch)
if (batch.length < 100) break
}
return out
}
async function fetchGiteaRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
const base = cfg.giteaUrl.replace(/\/+$/, '')
const headers: Record<string, string> = { Accept: 'application/json' }
if (cfg.giteaToken) headers.Authorization = `token ${cfg.giteaToken}`
const out: ForgeRepo[] = []
for (let page = 1; page <= 10; page++) {
const batch = await $fetch<ForgeRepo[]>(
`${base}/api/v1/users/${encodeURIComponent(cfg.giteaUser)}/repos`,
{ headers, query: { limit: 50, page } },
)
if (!Array.isArray(batch) || !batch.length) break
out.push(...batch)
if (batch.length < 50) break
}
// a token turns this endpoint into "everything i can see", so filter again
return out.filter((r) => !r.private)
}
// --- mapping ---------------------------------------------------------------
const yearOf = (iso: string): string => {
const year = new Date(iso).getFullYear()
return Number.isFinite(year) ? String(year) : ''
}
function deriveStatus(repo: ForgeRepo, pushedAt: string): ProjectStatus {
if (repo.archived) return 'archived'
const age = (Date.now() - Date.parse(pushedAt)) / 86_400_000
// anything quiet for a while is "done" rather than "abandoned"; site.json can
// still call it 'wip' if that is closer to the truth
return Number.isFinite(age) && age <= ACTIVE_DAYS ? 'active' : 'shipped'
}
function toProject(repo: ForgeRepo, source: 'github' | 'gitea'): RepoProject | null {
const name = repo.name?.trim()
const href = repo.html_url?.trim()
if (!name || !href) return null
const pushedAt = repo.pushed_at || repo.updated_at || repo.created_at || ''
const site = (repo.homepage || repo.website || '').trim()
const topics = (repo.topics ?? []).filter((t) => typeof t === 'string' && t.trim())
const language = repo.language?.trim()
return {
name,
tagline: repo.description?.trim() || '',
bullets: [],
// the language first, then whatever topics the repo declares
stack: [...new Set([language, ...topics].filter((s): s is string => !!s))].slice(0, 5),
year: yearOf(repo.created_at || pushedAt),
href,
links: site && site !== href ? [{ label: 'site', href: site }] : [],
featured: false,
status: deriveStatus(repo, pushedAt),
source,
fullName: repo.full_name?.trim() || name,
stars: repo.stargazers_count ?? repo.stars_count ?? 0,
pushedAt,
archived: repo.archived === true,
}
}
// --- site.json overrides ---------------------------------------------------
const normalizeHref = (href: string): string =>
href.trim().toLowerCase().replace(/\.git$/, '').replace(/\/+$/, '')
/** site.json wins wherever it actually says something */
function enrich(base: RepoProject, override: Project): RepoProject {
return {
...base,
name: override.name || base.name,
tagline: override.tagline || base.tagline,
bullets: override.bullets.length ? override.bullets : base.bullets,
stack: override.stack.length ? override.stack : base.stack,
year: override.year || base.year,
links: override.links.length ? override.links : base.links,
featured: override.featured,
hidden: override.hidden,
// `status` is never absent after normalisation, so it always overrides
status: override.status,
}
}
function asManual(project: Project): RepoProject {
return {
...project,
source: 'manual',
fullName: project.name,
stars: 0,
pushedAt: '',
archived: project.status === 'archived',
}
}
// --- assembly --------------------------------------------------------------
const rank: Record<ProjectStatus, number> = { active: 0, wip: 1, shipped: 2, archived: 3 }
function sortProjects(projects: RepoProject[]): RepoProject[] {
return projects.sort(
(a, b) =>
rank[a.status] - rank[b.status] ||
Number(b.featured) - Number(a.featured) ||
(Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) ||
b.stars - a.stars ||
a.name.localeCompare(b.name),
)
}
async function readSource(
kind: 'github' | 'gitea',
account: string,
cfg: ProjectsConfig,
load: () => Promise<ForgeRepo[]>,
): Promise<{ status: ProjectSourceStatus; projects: RepoProject[] }> {
if (!account) {
return { status: { kind, account: '', ok: true, count: 0 }, projects: [] }
}
try {
const repos = await load()
const projects = repos
.filter((r) => !r.private)
.filter((r) => cfg.includeForks || !r.fork)
.filter(
(r) =>
!cfg.exclude.includes((r.name ?? '').toLowerCase()) &&
!cfg.exclude.includes((r.full_name ?? '').toLowerCase()),
)
.map((r) => toProject(r, kind))
.filter((p): p is RepoProject => p !== null)
return { status: { kind, account, ok: true, count: projects.length }, projects }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[projects] ${kind} (${account}) failed:`, message)
return {
status: { kind, account, ok: false, count: 0, error: message },
projects: [],
}
}
}
async function loadProjects(): Promise<ProjectsPayload> {
const cfg = projectsConfig()
const [github, gitea, site] = await Promise.all([
readSource('github', cfg.githubUser, cfg, () => fetchGithubRepos(cfg)),
readSource('gitea', cfg.giteaUser, cfg, () => fetchGiteaRepos(cfg)),
getSiteData(),
])
// same repo mirrored on both forges: github is the public-facing one
const byKey = new Map<string, RepoProject>()
for (const project of [...github.projects, ...gitea.projects]) {
const key = project.name.toLowerCase()
if (!byKey.has(key)) byKey.set(key, project)
}
const byHref = new Map<string, Project>()
const byName = new Map<string, Project>()
for (const project of site.projects) {
const href = normalizeHref(project.href)
if (href) byHref.set(href, project)
const name = project.name.trim().toLowerCase()
if (name && !byName.has(name)) byName.set(name, project)
}
// urls first: a url names exactly one repo, where a name can match several
// (`mycelia` and `mycelia-experience` both answer to "mycelia")
const used = new Set<Project>()
const matched = [...byKey.values()].map((repo) => {
const override = byHref.get(normalizeHref(repo.href)) ?? null
if (override) used.add(override)
return { repo, override }
})
const projects = matched.map(({ repo, override }) => {
if (override) return enrich(repo, override)
// fall back to the name, but never let one entry claim a second repo
const byname = byName.get(repo.name.toLowerCase())
if (!byname || used.has(byname)) return repo
used.add(byname)
return enrich(repo, byname)
})
// things with no repository behind them (or whose forge is down) still count
const orphans = site.projects.filter((p) => !used.has(p)).map(asManual)
return {
projects: sortProjects([...projects, ...orphans].filter((p) => !p.hidden)),
sources: [github.status, gitea.status].filter((s) => s.account),
}
}
export async function getProjects(): Promise<ProjectsPayload> {
return cached(PROJECTS_KEY, projectsConfig().ttl, loadProjects)
}

83
server/utils/site.ts Normal file
View File

@ -0,0 +1,83 @@
import { normalizeSiteData, defaultSiteData, type SiteData } from '#shared/site'
import { useContentProvider } from './providers'
import { cached, bustCache } from './cache'
import { contentConfig } from './config'
const SITE_KEY = 'site:data'
export interface SiteDocument {
data: SiteData
/** blob sha of site.json, or null when the file does not exist yet */
sha: string | null
/** false when the repo has no site.json and the built-in seed is in use */
fromRepo: boolean
}
async function loadSite(): Promise<SiteDocument> {
const provider = useContentProvider()
const path = contentConfig().siteFile
let raw: string
try {
raw = await provider.read(path)
} catch {
// no site.json in the repo yet — the seed in shared/site.ts is the site
return { data: structuredClone(defaultSiteData), sha: null, fromRepo: false }
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
console.error(`[site] ${path} is not valid JSON, falling back to the seed:`, error)
return { data: structuredClone(defaultSiteData), sha: null, fromRepo: false }
}
const stat = await provider.stat(path).catch(() => null)
return { data: normalizeSiteData(parsed), sha: stat?.sha ?? null, fromRepo: true }
}
export async function getSiteDocument(): Promise<SiteDocument> {
return cached(SITE_KEY, contentConfig().ttl, loadSite)
}
export async function getSiteData(): Promise<SiteData> {
return (await getSiteDocument()).data
}
export function serializeSiteData(data: SiteData): string {
return `${JSON.stringify(data, null, 2)}\n`
}
export async function putSiteData(
input: unknown,
options: { message: string; sha?: string | null },
): Promise<{ sha: string; commit?: string; data: SiteData }> {
const provider = useContentProvider()
const path = contentConfig().siteFile
const data = normalizeSiteData(input)
// trust the live sha over whatever the client believed, unless the client
// sent one — then it acts as the optimistic-concurrency check.
const current = await provider.stat(path).catch(() => null)
const sha = options.sha === undefined ? current?.sha : (options.sha ?? undefined)
if (options.sha && current?.sha && options.sha !== current.sha) {
throw createError({
statusCode: 409,
message: 'site.json changed in the repo since you loaded it — reload and reapply',
})
}
const result = await provider.write({
path,
content: serializeSiteData(data),
message: options.message,
sha,
})
bustCache('site:')
// the project list is built on top of site.json, so it is stale too
bustCache('projects:')
return { sha: result.sha, commit: result.commit, data }
}

291
shared/site.ts Normal file
View File

@ -0,0 +1,291 @@
/**
* The shape of everything the site says about you.
*
* The values below are the *seed*: they ship with the build and are what the
* site falls back to when the content repo has no `site.json` yet, or when it
* has one that is broken. Once `/dash` writes a `site.json`, that file wins.
*/
export interface ProjectLink {
label: string
href: string
}
export type ProjectStatus = 'active' | 'wip' | 'shipped' | 'archived'
export interface Project {
name: string
tagline: string
/** one line per bullet, keep them short */
bullets: string[]
stack: string[]
year: string
href: string
links: ProjectLink[]
/** shown on the home page */
featured: boolean
status: ProjectStatus
/** keep this repository out of the public list entirely */
hidden?: boolean
}
export interface SkillGroup {
label: string
items: string[]
}
export interface NavItem {
label: string
href: string
}
export interface SiteData {
handle: string
name: string
domain: string
role: string
company: { name: string; href: string }
location: string
timezone: string
tagline: string
description: string
about: string[]
contactEmail: string
skills: SkillGroup[]
links: NavItem[]
nav: NavItem[]
projects: Project[]
}
export const PROJECT_STATUSES: ProjectStatus[] = ['active', 'wip', 'shipped', 'archived']
export const defaultSiteData: SiteData = {
handle: 'nak0x',
name: 'Théo Lesage',
domain: 'nak0x.dev',
role: 'software engineer',
company: { name: 'Teklia', href: 'https://teklia.com' },
location: 'Annecy, France',
timezone: 'Europe/Paris',
tagline:
'i write rust for machines that should have been retired, and vue for the ones that were not.',
description:
'software engineer in annecy, france. rust, vue/nuxt, and a homelab that runs more than it should. i like small binaries, linux, and making old hardware feel fast again.',
about: [
"i'm théo — `nak0x` most places. i build tools for myself first and then find out other people wanted them too.",
'by day i work at teklia on document understanding infrastructure. by night it is mostly rust, self-hosted things, and shaving megabytes off pages that had no business being megabytes.',
'i care about software that is small, legible, and survives a laptop from 2009.',
],
contactEmail: 'nak0x@proton.me',
skills: [
{ label: 'languages', items: ['rust', 'typescript', 'python', 'c#', 'glsl'] },
{ label: 'web', items: ['vue', 'nuxt', 'nitro', 'three.js', 'p5.js'] },
{ label: 'systems', items: ['linux', 'docker', 'coolify', 'gitea', 'nginx'] },
{ label: 'other', items: ['unity', 'shaders', 'iot / embedded', 'neovim'] },
],
links: [
{ label: 'github', href: 'https://github.com/nak0x' },
{ label: 'gitea', href: 'https://git.nak0x.dev/Nak0x' },
{ label: 'email', href: 'mailto:nak0x@proton.me' },
{ label: 'rss', href: '/rss.xml' },
],
nav: [
{ label: 'about', href: '/#about' },
{ label: 'projects', href: '/projects' },
{ label: 'blog', href: '/blog' },
],
projects: [
{
name: 'furst',
tagline: 'browsing the modern web on hardware that cannot run a modern browser.',
bullets: [
'a news page is 2–5MB over 80+ requests. the article itself is ~20KB.',
'extract, proxy, and route media to mpv — keep the engine out of the loop.',
'built for a core2 duo with 4GB and a minimal arch install.',
],
stack: ['rust', 'http', 'readability'],
year: '2026',
href: 'https://git.nak0x.dev/Nak0x/Furst',
links: [
{ label: 'guide', href: 'https://git.nak0x.dev/Nak0x/Furst/src/branch/master/GUIDE.md' },
],
featured: true,
status: 'active',
},
{
name: 'faust',
tagline: 'live markdown preview for obsidian vaults, with the look of a TUI.',
bullets: [
'never writes to your vault — you edit in nvim, it re-renders on disk change.',
'gpu rendering, cold start under a blink.',
],
stack: ['rust', 'wgpu', 'markdown'],
year: '2026',
href: 'https://github.com/nak0x/faust',
links: [],
featured: true,
status: 'active',
},
{
name: 'arkindex on coolify',
tagline: 'a standalone, one-compose deployment of arkindex.',
bullets: ['self-hosting a document-ml platform without the kubernetes tax.'],
stack: ['docker', 'coolify', 'python'],
year: '2026',
href: 'https://git.nak0x.dev/Nak0x/Standalone-Arkindex-Coolify',
links: [],
featured: true,
status: 'shipped',
},
{
name: 'mycelia',
tagline: 'an iot mesh that grows the way its namesake does.',
bullets: [],
stack: ['python', 'mqtt', 'embedded'],
year: '2026',
href: 'https://github.com/nak0x/mycelia-experience',
links: [{ label: 'v1', href: 'https://github.com/nak0x/Mycelia' }],
featured: true,
status: 'active',
},
{
name: 'mundara',
tagline: 'an immersive AR experience for cultures we forgot.',
bullets: ['unity + AR foundation, a website, and a very tired team.'],
stack: ['unity', 'c#', 'vue'],
year: '2025',
href: 'https://github.com/nak0x/Mundara',
links: [{ label: 'site', href: 'https://github.com/nak0x/mundara-website' }],
featured: false,
status: 'shipped',
},
{
name: 'dbz-explorer',
tagline: 'a dragon ball game built on a reusable engine of my own.',
bullets: [],
stack: ['python', 'game engine'],
year: '2024',
href: 'https://github.com/nak0x/dbz-explorer',
links: [],
featured: false,
status: 'archived',
},
{
name: 'dotfiles',
tagline: 'neovim, wm, shell — the whole desk.',
bullets: [],
stack: ['lua', 'zsh', 'nix-adjacent'],
year: 'ongoing',
href: 'https://github.com/nak0x/dotfiles',
links: [],
featured: false,
status: 'active',
},
{
name: 'dither guy',
tagline: 'graphic dithering for images and video, low-color-depth on purpose.',
bullets: [],
stack: ['python', 'pillow'],
year: '2026',
href: 'https://github.com/nak0x/dither-guy',
links: [],
featured: false,
status: 'wip',
},
],
}
// --- normalisation ---------------------------------------------------------
// `site.json` comes from a git repo a human edits by hand. Anything missing or
// mistyped falls back to the seed instead of taking the site down.
const str = (v: unknown, fallback: string): string =>
typeof v === 'string' && v.trim() ? v : fallback
const strList = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x.trim()) : []
const linkList = (v: unknown): ProjectLink[] =>
Array.isArray(v)
? v
.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object')
.map((x) => ({ label: str(x.label, ''), href: str(x.href, '') }))
.filter((x) => x.label && x.href)
: []
function normalizeProject(input: unknown): Project | null {
if (!input || typeof input !== 'object') return null
const raw = input as Record<string, unknown>
const name = str(raw.name, '')
if (!name) return null
const status = raw.status
return {
name,
tagline: str(raw.tagline, ''),
bullets: strList(raw.bullets),
stack: strList(raw.stack),
year: str(raw.year, ''),
href: str(raw.href, '#'),
links: linkList(raw.links),
featured: raw.featured === true,
hidden: raw.hidden === true,
status: PROJECT_STATUSES.includes(status as ProjectStatus)
? (status as ProjectStatus)
: 'active',
}
}
export function normalizeSiteData(input: unknown): SiteData {
const d = defaultSiteData
if (!input || typeof input !== 'object') return structuredClone(d)
const raw = input as Record<string, unknown>
const company = (raw.company ?? {}) as Record<string, unknown>
const skills = Array.isArray(raw.skills)
? raw.skills
.filter((g): g is Record<string, unknown> => !!g && typeof g === 'object')
.map((g) => ({ label: str(g.label, ''), items: strList(g.items) }))
.filter((g) => g.label && g.items.length)
: []
const projects = Array.isArray(raw.projects)
? raw.projects.map((p) => normalizeProject(p)).filter((p): p is Project => p !== null)
: []
const about = strList(raw.about)
const links = linkList(raw.links)
const nav = linkList(raw.nav)
return {
handle: str(raw.handle, d.handle),
name: str(raw.name, d.name),
domain: str(raw.domain, d.domain),
role: str(raw.role, d.role),
company: {
name: str(company.name, d.company.name),
href: str(company.href, d.company.href),
},
location: str(raw.location, d.location),
timezone: str(raw.timezone, d.timezone),
tagline: str(raw.tagline, d.tagline),
description: str(raw.description, d.description),
about: about.length ? about : [...d.about],
contactEmail: str(raw.contactEmail, d.contactEmail),
skills: skills.length ? skills : structuredClone(d.skills),
links: links.length ? links : structuredClone(d.links),
nav: nav.length ? nav : structuredClone(d.nav),
// an empty project list is a legitimate choice, so it is not overridden
projects: Array.isArray(raw.projects) ? projects : structuredClone(d.projects),
}
}
export const featuredOf = (projects: Project[]): Project[] => projects.filter((p) => p.featured)

28
shared/types/blog.ts Normal file
View File

@ -0,0 +1,28 @@
export interface TocEntry {
id: string
text: string
depth: number
}
export interface PostMeta {
/** url slug, from the frontmatter or the filename */
slug: string
title: string
description: string
/** ISO date */
date: string
updated?: string
tags: string[]
draft: boolean
/** minutes, rounded up */
readingTime: number
/** path of the .md inside the content repo */
path: string
/** link back to the file in gitea/github */
sourceUrl?: string
}
export interface Post extends PostMeta {
html: string
toc: TocEntry[]
}

53
shared/types/dash.ts Normal file
View File

@ -0,0 +1,53 @@
import type { PostMeta } from './blog'
export interface DashSession {
/** false when DASH_PASSWORD is unset — the dashboard is off */
enabled: boolean
authenticated: boolean
/** false when the provider has no write token */
writable: boolean
provider?: string
repo?: string
branch?: string
dir?: string
siteFile?: string
ttl?: number
webhook?: boolean
}
export interface DashPost extends PostMeta {
sha: string
}
export interface PostFrontmatterInput {
title: string
description: string
date: string
updated: string
tags: string[]
slug: string
draft: boolean
}
export interface PostDraft {
path: string
sha: string
sourceUrl: string
frontmatter: PostFrontmatterInput
body: string
}
export interface SavePayload {
frontmatter: Partial<PostFrontmatterInput>
body: string
message?: string
/** the sha the editor loaded; omit on create */
sha?: string
}
export interface SaveResult {
slug: string
path: string
sha: string
commit?: string
}

36
shared/types/projects.ts Normal file
View File

@ -0,0 +1,36 @@
import type { Project } from '../site'
export type ProjectSource = 'github' | 'gitea' | 'manual'
/**
* A project as the site shows it: a public repository read from a forge,
* optionally enriched by a matching entry in `site.json`.
*
* `manual` entries are the other way round — a `site.json` project that has no
* repository behind it (or whose forge was unreachable).
*/
export interface RepoProject extends Project {
source: ProjectSource
/** "owner/name", as the forge reports it */
fullName: string
stars: number
/** ISO date of the last push, '' for manual entries */
pushedAt: string
archived: boolean
}
export interface ProjectSourceStatus {
kind: ProjectSource
/** the account the repos were read from */
account: string
ok: boolean
count: number
/** why it failed, when it did */
error?: string
}
export interface ProjectsPayload {
projects: RepoProject[]
/** one row per configured forge, so the page can admit a source is down */
sources: ProjectSourceStatus[]
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}