A personal blog about life, engineering, and games. Built with Astro, styled with a custom "Engineering Logbook" editorial design, and deployed to Netlify.
- Astro (static output) with content collections for articles and talks
- Hand-written CSS design tokens (no Tailwind) — light mode, three typefaces
(Space Grotesk / Newsreader / JetBrains Mono) self-hosted via
@fontsource - Shiki for code highlighting,
remark-reading-timefor read times - RSS via
@astrojs/rss; sitemap via@astrojs/sitemap; contact form via Netlify Forms - Per-article social share cards rendered at build time with satori + resvg
(
src/pages/og/[slug].png.js);public/og-default.pngcovers the non-article pages - Client-side ⌘K search palette (
SearchPalette.astro) over an inlined index — no backend /stats— the log in numbers (entries, words, read time, per-year and tag breakdowns)- Home highlights the three newest published entries marked
featured; article recommendations prefer shared tags, then publication date. - RSS is linked in the navigation and footer. About includes selected projects.
- Contact submits through Netlify Forms, with
/contact/sent/as the confirmation page. JavaScript adds a sending state and inline errors that preserve the text; without JavaScript, the form uses Netlify's native POST and custom success page.
npm install
npm run dev # local dev server
npm run build # production build → dist/
npm run preview # preview the built site
npm run check # type-check (astro check)
npm run test # unit tests (vitest)Articles live in src/content/articles/*.md. Frontmatter:
idx: 1 # stable publication number (ascending by date)
title: "…"
date: 2020-10-02T00:00:00Z
slug: "the-beauty-of-node-streams" # the URL path (overrides the filename)
tags: ["nodejs", "streams"]
excerpt: "…"
draft: false
unlisted: false # true: accessible by URL, hidden from discovery
featured: false
series: # optional; use the same id/title on every part
id: js-to-go
title: "From JavaScript to Go"
order: 1 # positive integer; unique within the series
featuredImage: "../../assets/images/stream.jpg" # local asset, optional
# coverImage: "https://…" # remote image (dev.to imports), optional
# canonicalUrl / devtoUrl # set on imported articlesslug preserves the article's public URL — do not change it for existing posts.
Use draft: false with unlisted: true to share a work in progress by its usual
article URL. Unlisted articles are excluded from listings, search, stats, feeds,
recommendations, series navigation, Markdown/oEmbed exports and the sitemap.
Their pages request noindex, nofollow. Anyone with the URL can read or share
them; this is not access control. draft: true still prevents a page from being
built, even when unlisted is set. Files in drafts/ are also outside the content
collection; move one into src/content/articles/ with complete frontmatter to
make it accessible. Set unlisted: false when ready to list it publicly.
Series posts show their position near the title and an ordered list of parts with
previous/next links after the article. Only published parts count toward the
position and total; drafts and unlisted parts are excluded. Navigation appears once two parts are
published. Reading order comes from series.order, independent of publication
date. Leave series out for standalone posts.
Published parts with duplicate orders or conflicting titles for the same series
ID fail the build with an error identifying the affected entries.
Talks are listed in src/content/talks/talks.json.
Each talk's preview points to a first-slide image in public/talks/, and url
links to the original deck. Dates are Drive modification dates, displayed as
"Updated"; event is optional. The source decks are in the public
Talks folder.
- Get your dev.to API key (Settings → Extensions → API Keys).
npm run import:devto # optionally export DEVTO_API_KEY first- Re-run anytime — existing files are skipped when either the slug or the
article title already exists locally, so your hand-migrated posts are not
duplicated. Pass
-- --forceto overwrite. Note:--forcerewrites existing files and re-assignsidxfrom the current max, so it can renumber posts. Prefer the default (skip) for incremental imports; only use--forcefor a full re-sync and review the diff. - Review the new
src/content/articles/*.md(andgit status), then commit.
The blog is the canonical source; imported articles carry a devtoUrl that renders
an "also on dev.to ↗" mirror link.
Privacy-friendly, cookieless GoatCounter. Set the
PUBLIC_GOATCOUNTER environment variable (in the Netlify UI) to your count endpoint,
e.g. https://abcdev.goatcounter.com/count. When unset, no analytics script is
emitted, so local dev and previews stay tracking-free. The script also skips
localhost on its own. No cookie-consent banner is required.
Pushed to Netlify via CI. netlify.toml pins the build (npm run build → dist/)
and Node 22.
DEV is a syndication channel; publish the original on https://kevinaleman.com.
Create a key at https://dev.to/settings/extensions (DEV Community API Keys).
Set DEVTO_API_KEY in your shell environment or an ignored root .env file
(DEVTO_API_KEY=...). Never commit or share it. Node 22 is required.
npm run devto:sync # authenticated dry-run; no writes
npm run devto:sync -- --apply # explicitly apply reviewed changesUses the official Forem v1 API:
GET /api/articles/me/all with pagination (including drafts), then
PUT /api/articles/{id} with only { "article": { "canonical_url": "..." } }.
No body, title, tags, description, or publication state is sent.
Matching prefers an exact local devtoUrl, then an existing canonical path
on the current/old site, then exact DEV slug/local route correspondence, then
an exact title match ignoring case and whitespace. Ambiguous matches, conflicting
explicit links, duplicate targets, and local drafts are skipped as UNMATCHED.
Targets come from local slug (filename fallback), never from DEV titles.
Every target must return HTML successfully on the primary origin at the same path;
failed checks report INVALID TARGET. Rows show title, ID, current/proposed URLs,
and OK, UPDATE, UNMATCHED, or INVALID TARGET.
For UNMATCHED, verify the original manually and set its local devtoUrl to the
exact DEV article URL; rerun the dry-run. Do not change established local slugs.
For future syndication, publish/deploy the local post first, set DEV's canonical
URL to https://kevinaleman.com/<local-slug> in its editor, and save its devtoUrl
locally. Local pages derive canonical/OG URLs from their actual routes, regardless
of imported canonical metadata. Imports default to the primary domain; do not
set SITE_URL to the legacy deployment hostname.
Deploy these changes before applying synchronization. netlify.toml redirects
both HTTP and HTTPS requests on kaleman.netlify.app permanently to the primary
site, preserving paths and query strings. In Netlify Domain management, verify
kevinaleman.com is the primary domain on the same site that owns
kaleman.netlify.app. If the old hostname belongs to a separate Netlify site,
deploy the host-specific redirect rules there too. After deploy, check a real
article path with curl -I https://kaleman.netlify.app/<local-slug> for a 301 and
matching primary-domain Location; the primary URL must return 200 without a loop.
Each published article is also available at
https://kevinaleman.com/markdown/<local-slug>.md. The static export contains
the title, author, publication date, original HTML URL, and unmodified Markdown
body (without internal frontmatter or page navigation). Article HTML advertises
it with a rel="alternate" link of type text/markdown. Drafts are excluded.
This provides a lightweight format for readers and agents; it does not guarantee
AI discovery, citations, or inclusion in training. Deploy to make exports live.