web dev & more!

Introducing closingtags.com, version 2.0!

Goodbye, WordPress

When I started this website over a decade ago, it was intended to be a tool for me to experiment and learn. I wanted a place where I could not only teach others about the things I had learned, but a place where I could tinker. At the time, I worked predominantly with WordPress so it made sense for the site to be powered by WordPress.

But that was a long time ago and I don’t use WordPress very often anymore. When I want to make a change to my website, I find myself having to re-learn “the WordPress way” of doing things. I have to lookup how to create a plugin, how to enqueue my scripts and styles, or which hooks I’ll need. I frequently get frustrated by how the WordPress file system is structured, how I can’t manage dependencies with Composer, or how my database is controlling the look and layout of posts and pages.

I’d much rather have the data be independent of the look and feel of the site. With WordPress, there is always some portion of the site’s appearance that is controlled by data within the database. I’d also prefer that the data stored in the database not affect the functionality of a site. If I change the domain of a WordPress site, I’ll also inevitably have to run a search-replace to change that URL throughout the database.

I could probably get most of these features by migrating my installation to the Roots.io way but I don’t think it would resolve all of my complaints against WP. I’ve had a taste of modern development practices and WordPress just doesn’t go down as smoothly as it used to.

All of these barriers were preventing me from hacking or modifying my own website. It goes against the very reasons I started this site in the first place! I wanted to get back to tinkering, hacking, and breaking things. I want to build weird shit. Frustrated, I began building a new site using Svelte, SvelteKit, Tailwind CSS, and TypeScript because I knew this was a stack that I enjoyed working with.

And then Matt Mullenweg went to war with the WordPress community…

He claims he’s fighting the good fight against “venture capital firms” or some bullshit but let’s be real; the people hurt first in his crusade have been the community. I’m not going to defend a billion dollar corporation. I also won’t analyze or break this drama down any further because far more talented writers already have. Suffice to say, the WordPress project does not belong to the community as I and so many others were led to believe, but rather to Matt. This was enough to push me over the edge and kick development of my own site into high gear.

Planning the Migration

I was personally done with WordPress, I knew that much. But WordPress does a lot. Like, a lot. I knew I’d need to take account of everything I’d be losing before jumping ship. For instance, adding new posts or pages is as simple as logging in and clicking a button. Menu items can be rearranged by dragging and dropping them into place. There exists tens of thousands of plugins that can add countless features, all of which can be installed in a few clicks. Migrating to a custom built website would mean losing a lot of functionality. Building all of those features would now be my responsibility.

But this migration would also come with upsides. If I built a static site (HTML, CSS, and JS assets that can be served directly, without any back-end server logic) I wouldn’t need a database to store my posts. That’s one less dependency to update and manage. I wouldn’t have to remember to log in and run plugin updates either. While I’ve tried to only keep essential, trusted plugins installed on my site, even major plugins suffer attacks. Since WordPress plugins are notoriously insecure, this greatly reduces the potential attack surface on my simple site.

Requirements

After taking account of various features and functionality, I decided the following would be my priorities:

  1. Enjoyable writing experience: Being able to write posts using Vim/LunarVim would mean I could stay in the same environment I use when building the site. Posts should be written in Markdown, rendered as static HTML, and checked into version control where they would live alongside the code.
  2. Built with Svelte/SvelteKit. It’s kind of my thing lately.
  3. Keep old posts and comments. There was a lot of valuable information there and I didn’t want to lose any of it.
  4. Support categories on each post. This is just a nice way to organize each post.
  5. Don’t change post URLs. There are a few sites out there pointing to some of my content and I’d prefer they don’t 404 when users click them.
  6. Have a contact form that emailed me for each submission.
  7. Ability to search content.

Some of the features I didn’t care so much about were things like:

  • Writing and publishing from a mobile app (like with Jetpack)
  • Supporting “tags” on each post. If the posts could support categories, that would be plenty for me.
  • Being WordPress. God, I’m just so tired of WordPress.

Writing Experience

WordPress has Gutenberg and honestly, it’s great. It’s very flexible and allows for a lot more control than the Classic Editor ever did. But I often found myself trying to use the Vim shortcuts (BTW I use Vim) while typing long posts. In my experience, Firefox doesn’t handle Vim shortcuts well (Mozilla please fix), which often led to me closing tabs or unintentionally deleting content. My new site would need a much more fine tuned experience that catered to my specific needs.

Markdown and Static Posts

When I began building the new site, I knew I wanted to write in Markdown. I even mentioned how I wish I could have written my book in Markdown on the JS Party podcast since I’d be able to continue using my Vim keybindings!

Sure, I could write every post as a Svelte component which would have given me lots of control over formatting but wrapping each paragraph in <p> tags gets old very quickly. Besides, Josh Collinsworth had released his SvelteKit Blog Starter project which made creating a static site with SvelteKit a breeze. It allows for writing posts in Markdown using mdsvex. And if I ever want to include HTML or even Svelte components in the posts, I can since mdsvex supports both!

Writing posts in Markdown has the added benefit of keeping posts static. While this entire site isn’t prerendered, the marjority of it is. I’ll explain the singular endpoint that could not be prerendered in the section discussing my contact form.

Built with…

I mentioned how I used Josh’s SvelteKit Blog Starter project to get started. I really liked it but wanted to use TypeScript, Tailwind CSS, and Svelte 5. The starter template had none of those so it was up to me to support them. This involved refactoring a lot of JS code and converting it to TS (which is not terribly fun but makes for great practice), removing Josh’s styles, and upgrading to Svelte 5.

Svelte 5

When I wrote “SvelteKit Up and Running,” Svelte was on version 4. With the release of Svelte 5, a few things have changed. I wanted a way to play with these new features and since this site is supposed to be my space for tinkering, I figured this would be as good a time as any to start experimenting. The Svelte devs made it very simple to upgrade an existing project to Svelte 5 with the Svelte CLI. After running npx sv svelte-5, I could view the new syntax in my project and start playing with it.

State & Reactivity

With Svelte 5, the biggest changes deal with how reactivity is declared. In Svelte 4, we could create a reactive variable like so:

<script lang="ts">
  let x: number = 0;
</script>

Anytime x was assigned a new value, Svelte would automatically propagate those changes throughout the component or page. In Svelte 5, we now use runes to declare reactive state. Runes differ from the previous syntax in that they’re symbols which look like functions. The $state() rune lets us declare reactive state variables. Our reactive state syntax would now become:

<script lang="ts">
  let x: number = $state(0);
</script>

Props

Meanwhile, props (data supplied to a component) are also now handled by a rune. Instead of the previous method which used the export keyword like so:

export let goal = 0;

… we now use the $props() rune:

<script lang="ts">
  let goal: number = $props(0);
</script>

A major added benefit to using the $props() rune over the previous method, is that it supports TypeScript out of the box! It may have been possible to get types onto your props before but it wasn’t ideal.

Reactive Statements

In Svelte 4, when we wanted to declare a reactive statement or a reactive function, we did so using the $: syntax.

<script lang="ts">
  let x: number = 2;
  let y: number = 5;
  $: z = x * y;
</script>

If x or y ever changed values, z would too! But in Svelte 5, the $: syntax has been deprecated and replaced with the $derived() and $effect() runes. For most uses, they’re the effectively the same thing. However, $effect() is intended to be used (sparingly) with more complicated logic while $derived() works nicely with short, simple logic:

<script lang="ts">
  let {x, y}: {x: number, y: number} = $state({ x: 2, y: 5 });
  let z: number = $derived(x * y);

  let zz: number = 0;
  $effect(() => {
    zz = x * y;
  })
</script>

It does require getting a little more verbose with our types but I think it’s worth it to have that native TypeScript support on reactive statements.

I’m not going to cover all of the changes with Svelte 5. If you’re looking for one of those posts, I’d suggest the official Svelte blog and documentation which do an excellent job at explaining everything you could possibly want to know.

Tailwind CSS

I appreciate the effort that goes into making unique and beautiful websites. I truly do.

However, I am incredibly lazy. Say what you will about Tailwind but it is so nice for us lazy devs. Since I planned on purging all of the original styles from the starter template anyways, adding Tailwind was a no-brainer. It helped me get up and running quickly and kept styles consistent across my components. And now, it’s even easier to add to a Svelte project using the CLI.

TypeScript

TypeScript can be an absolute pain in the ass if you hate having type safety. But that very same type safety has saved my ass more times than I can remember. For this reason alone, I decided to convert the starter template to TS. I don’t love converting JS into TS, but it forced helped me to learn a lot about how Vite’s import.meta.glob worked.

Importing Old Data

I was certain that I did not want to lose my old content. I had 92 posts, 77 comments, and 104 media items. Fortunately, WordPress provides a handy feature for exporting data.

The WordPress dashboard showing a menu item with an arrow pointing to the 'Export' option in the 'Tools' menu.
It's so simple to export your data from WordPress.

Posts

The WordPress export feature provides an XML file of the site’s content. Custom posts, pages, comments, all of it. There exist a few different projects that can take that XML file and convert it into Markdown. I chose lonekorean/wordpress-export-to-markdown since it used Node and seemed well supported. If I don’t have to install yet another dependency to run a script, I won’t.

For the most part, this script did the job. Things that worked nicely were importing slugs, titles, and post metadata (categories and tags). But it didn’t handle formatting very well. I’ve had to go back and edit several posts by hand which isn’t great but I can’t really blame the tool for not anticipating how styles should be applied in a format that doesn’t technically support them. I’m certain I’ve missed fixing some posts so if you find anything broken on my site, please do let me know so I can get it fixed!

After getting all of the posts into Markdown, they need to be loaded into SvelteKit somehow. The starter template comes with a handy fetchPosts.js which I’ve modified slightly to work for my needs. Its too interesting to be crammed into this post so I’ll try and write something up about how it works in another post later on. Just know that Vite’s import.meta.glob does most of the heavy lifting. If you’re the curious type, take a look at the starter template code for a better idea of how this works.

Comments

While the new site doesn’t have a comment system (yet), I wanted to preserve the old comments as they provided valuable insights on at least one of my posts. Unfortunately, the wordpress-export-to-markdown script did not convert comments. After some searching, I came across WordPress to Jekyll which does a great job at outputting post comments… in YML 🤮.

Even though I didn’t want to work with YML, this was a step in the right direction. All I would have to do now is convert the YML to JSON. Simple enough, right?

It totally was!

To use the WordPress to Jekyll package, one simply calls node cli.js {WORDPRESS_EXPORT_SOURCE} {OUTPUT_DIR}. Looking at cli.js, it pulls in a function from another package to extract the comments like so:

const exportComments = require('wordpress-comments-jekyll-staticman');

I was curious what wordpress-comments-jekyll-staticman was doing so I pealed it open and took a look inside. What I found inside its src/main.js was that it was calling yaml.safeDump. I simply replaced that with JSON.stringify(), changed the output file’s extension from .yml to .json in the writeFile command, and re-ran the script. Just like that, all of my comments were in JSON! The only catch was that each comment was output as its own JSON file in a directory named after the parent post’s slug.

To remedy this, I put together a script that combined all JSON files within a directory and output a single file. I wrote a test (using Vitest) to ensure the code would output in a format I was expecting. When it was finally time to run the script, I used the Vitest test runner to create a new test, and ran the command on each directory path name of the JSON files I wanted combined. I also took the time to strip out any email hashes so as to not accidentally doxx commenters. One can never be too careful! See the script below:

merge.ts

import { readdir, readFile, writeFile } from 'node:fs/promises';

interface Comment {
	name: string;
	date: string;
	url: string;
	message: string;
	email: string;
}
type CensoredComment = Omit<Comment, 'email'>;


// directory where JSON files are stored
const dir: string = 'src/lib/comments/';

// import JSON files & combine into one object
export const getFiles = async (slug: string): Promise<CensoredComment[]> => {
	const files = await readdir(dir + slug);
	let comments: Promise<CensoredComment>[] | [] = [];

	comments = files.map(async (file) => {
		const contents = await readFile(dir + slug + '/' + file);
		return censorEmail(JSON.parse(contents.toString()) as Comment);
	});

	const total = Promise.all(comments);

	return total.then((comments) => ({ ...comments }));
};

// write to the file system
export const write = async (slug: string): Promise<void> => {
	const obj = await getFiles(slug);
	await writeFile('src/lib/comments/' + slug + '.json', JSON.stringify(obj));
};

// strip emails from comments
export const censorEmail = (comm: Comment): CensoredComment => {
	const { email, ...rest } = comm;
	return rest;
};

In merge.ts, there are three functions. The first, getFiles accepts a string (the post slug and directory name) and returns a Promise containing an array of CensoredComments. This function will read in the entire contents of a directory and read each file inside that directory. Once it has parsed each of those files as JSON, it runs each JSON object through the third function censorEmail which strips the email address from a Comment object, and returns the array of CensoredComments. The second function, write() will take the array of CensoredComments and write it to a single file using the provided slug.

After using the Vitest test runner to run this on the 13 different directories (I just manually called write("dir_name_here") inside a test and ran it lol), I’m given 13 different JSON files. Those files can then easily be imported with Vite’s import.meta.glob based on the requested post slug. After all of that effort, I was able to keep comments from my old WordPress site!

I have yet to incorporate a commenting system on this new site. I plan to get around to it, but for now, I’m just trying to get the site out there so I can get back to writing. Stay tuned for future updates by subscribing to the RSS feed!

If you ever commented on one of the posts from my (old) website and would like it removed, please reach out to me using this contact form!

URLs

I know there are GitHub issues and forum posts that link specific articles on my site, and I didn’t want to break those posts by changing the post URLs. The starter template came with some opinions on where blog posts should be located, mainly routes/blog/[post]. That didn’t work for me so I simply moved the posts up one directory to routes/[post]. The logic is all the same but now SvelteKit’s routing mechanism will check if the request is for the base routes/+page.svelte, and if not, check if a post exists at the requested URL. If there is no post, then it’s 404 Page Not Found error.

In the future, I may move posts back under routes/blog/[post] or even routes/posts/[post] and simply redirect any traffic from the original base route to the new one. That only seems necessary if I have other posts types. It’s very likely I will have more post types but for now, I don’t so I’m not worried about it.

If you’ve subscribed to my site via RSS, that shoule still work too. The starter template includes an RSS feed at routes/api/rss.xml but I’ve also added a route at routes/feed which simply serves the content from routes/api/rss.xml.

If you’re looking for more information on how SvelteKit routing works, you can check out my book which explains the how to manage static and dynamic routes with some simple exercises. Or you can read the official documentation.

Contact Form

Most normal people would identify the lack of a server back-end as a problem. But any programmer worth their salt would correctly label it as a feature.

In this case, that feature is interfering with functionality I would like to have on my site; the contact form. You see, a static site doesn’t have the ability to send an email unless it’s on a platform like Netlify as suggested in the starter template. I like Netlify and have even hosted some projects there myself. I have nothing against it but I have a couple of experiments that I’d like to build into this site eventually, and those experiments will require a server back-end. Instead of migrating it all later, I went ahead and got the difficult part out of the way, building the contact form functionality right away. As I mentioned earlier, there is one single point on this site that is not prerendered and that’s the endpoint which handles contact form submissions.

I could have used any dozens of the SDKs for email sending services that exist in the NPM ecosystem. Instead, I opted for something that was platform agnostic. Who knows how much my email provider will piss me off in the future, forcing me to change providers? I decided I would use the popular nodemailer package. On the +page.server.ts I can import various environment variables like the SMTP_HOST, SMTP_PORT, and credentials. If I do want to change email providers, I simply have to change the values in my .env file.

For spam protection, I wanted to develop a solution using honeypot form fields. But that wouldn’t prevent someone from going to the form and submitting junk values hundreds of times in a row. That would lead to my inbox being flooded with garbage emails and potentially land me with a steep bill from my email service provider. Instead, I opted to use Cloudflare’s Turnstile. It’s easy to setup and well documented.

One feature I wanted on my site that turned out to be more complicated than I had anticipated was the search form. It turns out, if you can build a fast search engine, you’ve got yourself a billion dollar business. Who knew?

In my fumbling research and naïveté, I came across this post from Joy of Code. It’s a simpler version that breaks down how the search on the new Svelte Omnisite works and I greatly appreciated it. Building the search breaks down to a few simple steps:

  1. Install flexsearch
  2. Import the data as a static asset
  3. Create a search index and function
  4. Build a UI
  5. Use regex to match search results
  6. Load the data using web workers

It’s incredibly interesting and I learned a lot from this post. There is also an accompanying video if that’s more your speed. I found myself digging into the official Svelte Omnisite code to see how the Svelte devs tackled this problem but I found Joy of Code’s explanation was so clear and concise, that I kept coming back to it.

Source Code

At this time, I’m not yet ready to share the source code. One of the reasons is selfish; I don’t want anyone to copy this site directly. It’s not that I’m embarrassed by the code (though I probably should be) but rather GitHub makes it too simple to copy a repository and claim the efforts as your own. Josh W. Comeau explains it well in his post Why My Blog is Closed Source.

Another reason is because I plan to keep “draft” posts saved alongside the published posts. Drafts aren’t accessible from the site but would be visible in a public repository. That means all of my half-baked posts that haven’t been proofed yet would be publicly visible 😨! I don’t really want those being read before they’re done because my writing process is, uh, messy.

In the future, I hope to open-source the whole site but at this point, I’m just not ready to.

Future Features

Since this site is supposed to be a space for me to hack and learn, I hope to add a few more features. I won’t list them all but a few quality of life improvements would be things like opening all links to external sites in a new tab, estimated read times on each post, and federated commenting. Of course, this post will likely be outdated and I’ll have added new features shortly after this post is published but hey, that’s technology for you, right?

TL;DR

I completely rebuilt this site from the ground up using Svelte and SvelteKit. It took me a long time and it was really hard. But I did enjoy it and I’m proud of my efforts. If you want me to build a site like this one for you, let me know using this contact form.