Skip to content
lucian-dev.com

How this site is made

The stack, type, hosting and privacy model behind lucian-dev.com, and every release — generated from the repository when the site is built.

Stack

Read from this site’s package.json when the page is built, so the versions are the ones serving it. The same list, with more detail, is on /uses.

Framework
Next.js15.5, React18.2, TypeScript5.3
Interface
Chakra UI2.8, Framer Motion10.15, dnd kit6.3, Tiptap3.31
Content
MDX3.1, GFM4.0, Shiki4.1, gray-matter, sharp0.35, OG images0.11
Data
SQLite12.10, Cloudflare R2, MongoDB6.3
Platform
Sentry, JOSE (JWT)4.15, Nodemailer10.0
Infrastructure
Docker, Node.js22.23, Cloudflare

Type

Set in Geist and Geist Mono by Vercel, released under the SIL Open Font License 1.1 (licence text). Both are self-hosted variable fonts — no font CDN, no third-party request — and the same faces render the social cards. Chinese text falls back to the system’s CJK fonts.

Hosting

  • Next.js (Pages Router) and React, built into one Docker image and run on a VPS in Tokyo.
  • Traffic arrives through a Cloudflare Tunnel: the server opens no inbound ports, and Cloudflare terminates TLS.
  • Content lives in SQLite on the server and is mirrored to Cloudflare R2 off-site; whole-site snapshots (database, uploads, history) are taken on top of that.
  • Pages are server-rendered or regenerated in the background when content changes, and every deploy is health-checked with an automatic rollback.

Self-assembling pages

/now shows what I write there when I have written something. Otherwise it assembles itself from data already on the site: current roles and studies from the public CV (entries hidden from the website never appear), the “currently coding” note from the home page, and the latest post and case study. Its status line gives the date the newest of those sources last changed, and the page regenerates within a minute of a change.

/uses works the same way: my own write-up first when there is one, then the stack above, read from package.json each time the page is generated.

Privacy & analytics

The site counts visits with its own first-party analytics. There are no cookies, no identifiers stored in your browser and no third-party analytics scripts.

  • Each page view records the path, the referring site (host name only), the country from Cloudflare’s country header, a coarse device and browser class, the page language and a visitor ID.
  • The visitor ID is a truncated hash of your IP address and browser user agent combined with a secret salt that is kept only in memory and replaced every day. It cannot be reversed, and the same visitor gets a different ID the next day. Your IP address and user agent are never stored.
  • If your browser sends Do Not Track (DNT: 1) or Global Privacy Control (Sec-GPC: 1), nothing is recorded. Bots and my own visits are not counted either.
  • Real-user performance timings (Core Web Vitals: LCP, INP, CLS, FCP, TTFB) are recorded per page with the device class only — no visitor ID.
  • The data stays in a separate database file on the server, is never part of the content backups or the R2 mirror, and is deleted after at most 400 days.

Build

This page was generated on from commit 82f0ce0. The source is on GitHub.

Changelog

2.2.0

A research-driven round (benchmarks of leading developer portfolios and CMS admins, live visual/performance/accessibility/SEO audits) on a new platform: Next.js 15.5 and patched dependencies.

Security

  • Next.js 14.2 → 15.5.27 (Pages Router, React 18): closes the image optimizer RCE/DoS, rewrite SSRF/smuggling and SSG/ISR cache-poisoning advisories. On-request image optimization is disabled (/_next/image → 404); uploads are resized and converted at upload time instead.
  • Tiptap 3.31.3, prosemirror-view 1.42.6, nodemailer 10.0.12, sharp 0.35.5 and resolutions for vulnerable transitive packages; CI audit gate with a reviewed allowlist (build-time tooling only).
  • MDX renders from a server-built tree without eval; the production CSP no longer allows 'unsafe-eval'. Embeds are an allow-list (YouTube, Vimeo, figures).
  • Every server-side fetch of a content- or admin-supplied URL goes through lib/security/safeFetch (public addresses only, DNS pinned, redirects re-checked, size and time limits).
  • Uploaded and imported images lose their EXIF / XMP (GPS, camera serials) — losslessly when no resize or rotation is needed — and images that decode to more than 80 MP are refused with 413.
  • Compiled articles and the feeds only carry safe link and image URLs (javascript: / data: are removed); embeds accept player URLs only.
  • Signing out also ends Draft Mode, and the Draft bar's Exit works without a session.
  • Analytics records article paths only for published slugs and gives each client and network a bounded share of the event budget.
  • The tracked CV seed holds only public content; an old snapshot is no longer tracked.

Added

  • Typography & motion: real Geist Sans/Mono (two preloads, ~141 KB), balanced headings and tabular figures; circular theme reveal from the toggle; directional page transitions with a pinned header; blur-rise hero headline; cursor-lit card borders and hover-intent lists; scroll-driven experience rail; shared-element photo lightbox; number roll-ups. All transform/opacity only and off under reduced motion.
  • Home: faster hero (pre-baked responsive WebP), Skills from the CV, writing & case-study links, full-width Field Notes, GitHub data rendered in the HTML, indigo contribution graph.
  • Command palette 3.0: loads on demand, searches posts, case studies and CV entries, owner actions (inbox, backup, log out), vCard and booking.
  • Articles: GFM tables/strikethrough, zero-CLS responsive images, refined reading layout, full-content /feed.xml, /work/feed.xml, /feed.json, Draft Mode with an Edit pill.
  • Pages: /cv/zh as a real Chinese page (title, description, JSON-LD), per-entry CV anchors, contact availability card, vCard and QR, /colophon generated from the repository.
  • Authoring: local drafts and conflict-safe saves for every editor, item history and undo-delete, compile-checked publishing with a checklist, side-by-side live preview, slash menu, upload-time WebP variants and safe remote image import.
  • Admin: status hub with "Needs attention", Save and leave dialog, keyboard shortcuts overlay, sortable home cards, CV/home validation, and first-party cookieless Analytics with real-user Web Vitals.
  • Operations: scripts/deploy.sh (bundle-based deploy with backups, health/warm-up gating, smoke test and automatic rollback), scheduled change-aware backups, structured JSON logs with request ids, Playwright + axe browser tests and a performance budget in CI.

Changed

  • Shared JavaScript 225 → 204 kB (_app 137 → 114 kB): palette, drawer and below-the-fold widgets load on demand; brand icons are inline SVG.
  • docker-compose.override.yml is no longer tracked; copy docker-compose.override.example.yml on a new server.
  • Lint runs through the ESLint CLI over the whole repository.
  • Upload variants keep the original's extension (photo.jpg.w640.webp), so originals with the same stem never share variants.

Fixed

  • Lists, quotes and the slash menu's list items in the editor (a duplicate prosemirror-model broke wrapping and list splits).
  • A post with an image inside a sentence no longer fails hydration; inline code wraps instead of being clipped on narrow screens.
  • RSS publish times match the JSON Feed and structured data.
  • Keyboard focus no longer dims the other rows of hover lists; the hero name appears at once under reduced motion; the lightbox allows pinch-zoom.
  • The vCard no longer falls back to the built-in name when the owner hides the name fields.
  • scripts/deploy.sh: a live analytics write during the backup no longer aborts the deploy, --force on the same commit recreates the container, and a dropped connection during the build restores the server.

2.1.0

Hardening and polish on top of 2.0.0, after two adversarial reviews (81 confirmed findings fixed) and an end-to-end check against a copy of the live content store.

Added

  • Admin Inbox (/admin/dashboard?tab=inbox, Alt/⌃+5): every contact message is stored with whether it was emailed — readable and answerable (reply-by-email with the original quoted) even when SMTP is not configured. Read/unread, delete, unread badge, keyboard navigation. Site-wide daily and size caps; full-site backups include it and restores merge it without bringing deleted messages back.
  • CV Studio: hidden-items panel (every hidden section/entry/field/bullet in EN and 中, jump-to and one-click Show), A4 CV / Website preview, alignment badges for linked EN↔中 lists, drafts that remember the version they were based on.
  • Admin-only reads with optimistic concurrency: GET /api/admin/cvdata and /api/admin/home return the full documents with a version ETag; saves send If-Match and get 409 instead of silently overwriting newer edits. Public GET /api/cvdata / /api/home always return the pruned view, even to a signed-in admin.
  • Mobile CV reading mode with a sticky Download PDF / EN·中文 / Copy link bar; self-updating /now and /uses built from the public CV, the currently-coding note, the latest writing and the curated stack.
  • Articles: heading anchors, sticky table of contents, reading progress, prev/next and related items; command palette 2.0 (fuzzy ranking, recents, in-page jumps, CV / theme / link actions).
  • On-demand revalidation: saving a post, case study, page, CV or home page refreshes the affected static pages immediately; a boot-time warm-up (scripts/warm-isr.mjs) replaces the image's build-time pages before traffic arrives.
  • Persistent header/footer across navigation, native view transitions, real PWA icons, AA-contrast accent tokens.

Fixed

  • next build failed with "database is locked" when the store did not exist yet (always the case in the Docker build).
  • Next's res.json() replaced version ETags with a body hash, so every conditional save conflicted (lib/http.ts sendVersionedJson).
  • Editors could load the pruned public CV after a session expired and save it back, deleting hidden content.
  • A full-backup restore was undone on the next boot; public pages kept reading the old database after a restore; failed R2 writes could be reverted by the boot sync (r2_pending now repairs them).
  • Hidden end dates showed as "Present", hidden expiry dates as "No Expiry"; linked hide toggles could hit the wrong 中 entry; "Sync 中 ← EN" dropped 中-only hides; duplicating a hidden entry made it public.
  • Home page layout shift on phones (CLS 0.28–0.48 → none); light-mode dark flash; portals without the site fonts; /cv language not following client navigation; social cards blocked by robots.txt.

Security

  • Middleware refuses cross-site and non-JSON writes to admin/content APIs.
  • Login: per-(email, IP) lockout plus a real site-wide throughput cap under attack, a signed known-device cookie for the owner, identical responses for unknown e-mail vs wrong password, async scrypt.
  • Logout and password reset revoke every issued session.
  • Client-IP headers trusted only from loopback and the container's default gateway unless TRUSTED_PROXY_IPS is set.
  • Contact form: the sum is issued and verified by the server (HMAC bound to the single-use nonce); JSON-LD escaped against script breakout; one upload-name rule for upload, serving and backups.

2.0.0

Added

  • CV visibility at every level. Hide / show any section (separately for the CV/PDF and for the website), any entry (degree, job, project, highlight, certificate…), any field (e.g. Education → GPA, phone, email) and any single bullet or tag. Hidden content is removed server-side from /, /about, /cv and the unauthenticated GET /api/cvdata (lib/cvVisibility.ts, readPublicCvData()).
  • CV Studio rewrite (components/Admin/CVEditorStudio.tsx, components/Admin/cvStudio/*): schema-driven forms for every section (lib/cvSections.ts), per-field eye toggles, collapsible entry cards with drag-and-drop / duplicate / delete-with-undo, bullet editor (Enter = new bullet, Backspace on empty = remove, Alt+↑/↓ = move, multi-line paste), tag input, month pickers with "Present", one undo history for EN + 中 with typing coalesced per field, EN ↔ 中 linked visibility & structure, in-editor "Sync 中 ← EN" (undoable), local draft recovery, overwrite guard when the CV changed elsewhere, live fit-to-width A4 preview with page-break guides, responsive panes.
  • Patents and Languages sections now render on the A4 CV and on /about (patents data existed but was never shown).
  • /cv?lang=zh and /cv?print=1; language-aware dates (Sep 2024 / 2024.09), labels (至今, 预计, 电话, 邮箱) and Chinese name order.
  • Curated, grouped tech stack (/api/stack) instead of a raw dependency dump; copy button on MDX code blocks; real G-then-key navigation shortcuts in the command palette.

Fixed

  • Studio wrote Awards to competitionAwardsList and Extra Skills to extraSkills (the viewer reads awards / points), so those edits never appeared; missing editors for highlights, related skills, certificate details and patents.
  • Header: menu missing on phones for 7 routes and on all tablets; sticky header never stuck; links did full page reloads; Dashboard / Logout never appeared (httpOnly cookie read from JS); the "last commit" pill wrapped onto three lines beside the full nav.
  • Affiliation logos overflowed the page horizontally on tablets (fixed 160px chips in a 4-column grid).
  • Dashboard mounted every editor at once — the Studio's ⌘S/⌘Z fired on other tabs and editors overwrote each other's saves.
  • Image uploads over ~750 KB failed (1 MB body limit); same-name uploads overwrote each other; PNG logos lost transparency.
  • Home editor could wipe the home page if its load failed; upload callbacks reverted edits made during the upload.
  • Raw JSON editor rewrote every curly quote in CV text on save.
  • Version restore didn't snapshot the current state first; snapshot times were shown in the wrong timezone.
  • Backup restore hung on a corrupt archive; non-atomic DB swap; local snapshot path check could be bypassed.
  • MongoDB backup/restore used stale seed files instead of the live store (restores silently changed nothing); image restore allowed path traversal; connection string returned to the browser and cached in localStorage.
  • MDX frontmatter dates crashed static generation; deleted posts could be resurrected from disk seeds or a stale R2 copy.
  • Floating section nav targeted IDs that didn't exist; hero typewriter leaked timers; a marginTop: 320px hack triggered by any URL containing "edit"; About hero hook-order bug; syntax highlighting colours never applied.
  • Blog/work editors: tags couldn't contain commas/spaces while typing, fields editable before load, "New" could overwrite an existing slug.

Security

  • Login lockout is now per (account, client IP) with a global breaker, so a stranger can no longer lock the admin out; failures against a wrong email no longer count. Admin email compared case-insensitively.
  • One shared admin-auth helper with the JWT algorithm pinned (HS256).
  • Rate limits keyed per route + IP (one endpoint can't exhaust another's budget), bounded memory, optional TRUSTED_PROXY_IPS.
  • Contact nonces audience-scoped; contact form returns an error when a message could be neither mailed nor stored; mailer no longer logs bodies (reset links); SMTP timeouts.
  • Reset-password validates token / password before consuming the token.
  • Uploads: AVIF brand check, never overwrite, served with a sandboxing CSP; CSP drops unused hCaptcha / Binance hosts; production browser source maps disabled.

Changed

  • Docker: named volumes for /app/data and /app/public/uploads (admin password changes, history, backups and uploads now survive redeploys); NEXT_PUBLIC_* passed as build args; runtime state excluded from the build context. CI on Node 22. Boot-time R2 sync keeps newer local edits.
  • GitHub proxies: fetch timeouts, shared in-flight requests, short failure caching, CDN cache headers; stats hide instead of showing zeros.
  • Accessibility: skip link + <main> landmark, one h1 per page, labelled icon buttons, accessible carousel (pause / play, keyboard, captions as alt text), combobox semantics in the command palette, prefers-reduced-motion honoured by every animation, AA contrast fixes.
  • Removed 38 unused dependencies (MUI, FontAwesome, Formik, axios, next-auth, i18n stack, styled-components, luxon, …) and ~40 dead files; lint is at zero warnings.

GitHub (opens in a new tab)GitLab (opens in a new tab)LinkedIn (opens in a new tab)WhatsApp (opens in a new tab)
© 2026 lucian-dev.comAll rights reserved