After the big layout changes from part 3, I had a blog that looked different but still felt half-done. The nav was overcrowded. My content directory was a mess. Scheduled posts required me to manually trigger deploys. None of this was visible to readers, but it bugged me every time I opened the project.
This post is about all those little things.
The nav problem
My old navigation had every page listed as a flat row of links: Posts, Notes, Links, Tags, Now, Uses, Almanac, Blogroll, About, Archives, Search, plus a theme toggle. Twelve items. On mobile it was a scrolling disaster, and even on desktop it felt cluttered.
The fix was grouping. I ended up with three dropdown menus: Logs (Posts, Notes, Tags), Bookmarks (Links, Blogroll), and ./about (About, Now, Uses, Almanac). Archives, Search, and Theme stayed as icon buttons on the right side.
For the dropdowns I went with native <details> and <summary> elements instead of building a custom component from scratch. They handle keyboard interaction out of the box. Enter and Space toggle them, they have proper ARIA states, and screen readers understand them without extra work from me.
I did need a little JavaScript on top. Three things: opening one dropdown should close the others, clicking outside should close all of them, and pressing Escape should close them too. Here’s the relevant bits from the Header script:
const dropdowns = document.querySelectorAll("[data-dropdown]");
// Opening one closes othersdropdowns.forEach(dropdown => { dropdown.addEventListener("toggle", () => { if (dropdown.open) { dropdowns.forEach(other => { if (other !== dropdown && other.open) { other.open = false; } }); } }, { signal });});
// Click outside to closedocument.addEventListener("click", event => { dropdowns.forEach(dropdown => { if (dropdown.open && !dropdown.contains(event.target)) { dropdown.open = false; } });}, { signal });
// Escape to closedocument.addEventListener("keydown", event => { if (event.key === "Escape") { dropdowns.forEach(dropdown => { if (dropdown.open) { dropdown.open = false; dropdown.querySelector("summary")?.focus(); } }); }}, { signal });Notice the { signal } on every listener? That’s an AbortController pattern. Without it, Astro’s view transitions cause a specific problem: each page navigation re-runs the script, registering duplicate event listeners. After navigating five pages, you’d have five copies of each handler. The cleanup looks like this:
let navAbort = null;
function initNavigation() { navAbort?.abort(); navAbort = new AbortController(); const { signal } = navAbort;
// ... all listeners use { signal } ...}
initNavigation();document.addEventListener("astro:after-swap", initNavigation);Every time initNavigation runs, it aborts the previous controller, which automatically removes all listeners tied to that signal. Then it creates a fresh controller for the new set. Clean.
A tech stack that reflects reality
My sidebar had a tech stack section, but it was basically a wishlist. I’d thrown in things I barely touched and left out things I use daily.
I wanted to make it honest. So I grep’d through all 107 markdown files in the blog directory and counted mentions. Python showed up 288 times. Docker 121 times. Kafka, Spark, and BigQuery all had real representation from my data engineering posts. Meanwhile, some entries were LinkedIn pollution, things like “Chemistry” and “Microsoft Word” that somehow ended up in my skills list years ago. Those got cut.
I cross-referenced the blog counts with my recent GitHub repos to pick up things I actively use but haven’t blogged about yet, like Zig and Hono and Cloudflare Workers.
The result is a typed constant in constants.ts:
export const TECH_STACK: Record<string, string[]> = { Languages: [ "Python", "Golang", "Java", "JavaScript", "TypeScript", "PHP", "Bash", "SQL", "Rust", ], "Data & Streaming": [ "Spark", "Kafka", "Airflow", "BigQuery", "Redshift", "MaxCompute", "Jupyter", ], Databases: ["PostgreSQL", "MySQL", "MongoDB", "SQLite", "Redis"], Cloud: ["AWS", "GCP", "Alibaba Cloud", "Cloudflare"], "Web & Frameworks": ["Django", "Flask", "Astro", "FastAPI", "Hono"], DevOps: [ "Docker", "Kubernetes", "Terraform", "Nginx", "Caddy", "Github", "GitLab", "Linux", "Ansible", ], AI: ["Claude", "OpenAI", "LMStudio", "OpenClaw"],};Rendering it is straightforward. Loop over the keys, render each category as a heading, and list the items underneath. The categories give it structure without turning it into a resume.
Year-prefix file migration
This one’s boring but I’m glad I did it. I had 105 blog posts all sitting in src/data/blog/ with names like some-post.md. No chronological ordering. Finding a specific post meant scrolling through an alphabetical list and guessing.
I wanted to rename everything to YYYY-some-post.md. The year prefix makes the directory scannable at a glance and groups posts by era.
The catch is that Astro’s glob loader derives entry IDs from filenames. Rename some-post.md to 2024-some-post.md and suddenly the URL changes from /posts/some-post to /posts/2024-some-post. That breaks every existing link.
The solution: add an explicit slug field to each post’s frontmatter before renaming. Astro respects slug as an override for the filename-based ID. I wrote a migration script:
async function processFiles() { const files = (await readdir(CONTENT_DIR)) .filter(f => f.endsWith(".md"));
for (const filename of files) { const filepath = join(CONTENT_DIR, filename); const originalSlug = filename.replace(".md", ""); let content = await readFile(filepath, "utf-8");
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) continue;
let fmContent = fmMatch[1];
// Add slug if missing if (!fmContent.includes("slug:")) { fmContent = fmContent.replace( /(title:.*)/, `$1\nslug: "${originalSlug}"` ); }
// Get year from pubDatetime const dateMatch = fmContent.match( /pubDatetime: ["']?(\d{4})-\d{2}-\d{2}/ ); if (!dateMatch) continue;
const year = dateMatch[1]; const newFilename = `${year}-${originalSlug}.md`;
// Write updated frontmatter, then rename content = content.replace( /^---\n[\s\S]*?\n---/, `---\n${fmContent}\n---` ); await writeFile(filepath, content); await rename(filepath, join(CONTENT_DIR, newFilename)); }}It backs up the directory first, processes each file in order, skips anything already prefixed, and logs what it does. I ran it once, verified no URLs broke, and deleted the backup.
I also updated the content scaffolding script (bun run new) to automatically prefix new posts with the current year. One less thing to think about.
Scheduled posts and auto-publishing
I had a scheduledPostMargin config option in Astro that lets you set a future pubDatetime and have the post hidden until that date arrives. The problem: this only works when the site rebuilds. My Cloudflare Pages deploys only triggered on git push. If I scheduled a post for Tuesday morning but didn’t push anything on Tuesday, it just wouldn’t appear.
The fix was a GitHub Actions cron job. It runs scripts/check-scheduled-posts.ts every 15 minutes, looks for posts with a pubDatetime in the near future, and triggers a rebuild if it finds any. No wasted builds when nothing is scheduled.
Wrapping up
None of this makes for exciting screenshots. Dropdown menus, file renames, and cron jobs aren’t the kind of thing you show off. But this is the layer that makes a blog feel finished instead of “in progress.”
This is part 4 of a series about redesigning this blog.