Capyblogger

Yet Another Static Site Generator

2024-11-07

There are a billion and more static site generators — more specifically, static blog generators. But I wanted much more fine-grained control over my blog, and I liked the idea of being able to add whatever features I wanted. Here’s an intro to my static site generator: Capyblogger.

The Stack

The Framework

I’m not brave enough to use vanilla JS, not when I can pick out a framework that supposedly takes care of all of the really difficult stuff for me. I’m also not masochistic enough to submit myself to React again. I had to use it for my senior project, and if I can help it, I never will again.

Lucky for me, I ran into Svelte and SvelteKit while setting out to build a federated novel reader. It’s fast, it’s straightforward, and it doesn’t leave me one update behind because it has decided there’s not enough change in the virtual DOM to update the real one.

In that React project I had to add a keyboard to the screen, and the input text bar was always one character behind, no matter what I did to get the app to re-render. I ended up leaving it as-is because I didn’t want to have to rip out the guts to get something so simple to work.

SvelteKit feels very lightweight and is easy for me to follow. I’m not a big fan of JSX, and Svelte’s templating language just makes sense to me. Plus, the compiler makes me feel like I’m still connected to my low-level compiled language roots.

The Format

I could’ve jumped on the bandwagon and just picked some flavor of Markdown to write my posts in. In fact, I probably would’ve been able to take advantage of someone else’s project. But I wanted something more consistent than Markdown, and I wanted to make sure it was capable of any formatting I could want in the future. Enter the Asciidoc format: powerful formatting from any plaintext editor.

Asciidoc was meant to be a "plain-text alternative to the DocBook XML schema" , which means it’s capable of way more than I will probably ever need for this blog. And that’s what I wanted, lower chances of having to hunt online for a Markdown extension or, God forbid, another flavor if I want to add in more capability. Also having to keep in mind whether the parser can handle my Frankenstein format…​

I’d rather go with the format that’s already as expressive as I need and is meant to be extensible if I want.

Asciidoc ships with an in-house processing tool: Asciidoctor. It’s written in Ruby, a language I’m not familiar with and am not comfortable adding to my stack. But it also has been officially ported to Java and JavaScript, which made it almost too easy to pop into Capyblogger.

The issue with Asciidoctor that I have been running into is that importing it causes a "chunk is too big" warning when being processed by Vite. I haven’t bothered doing anything about it, however, because it’s at most a few hundred kB? I think my app can survive that hit to memory and performance.

The Runtime

Node is practically a no-brainer for a Javascript runtime. Except I associate it with my senior project, which was painfully slow when spinning up the dev server. I was not impressed. So when Bun came around as a Node drop-in, shiny, new, and touting a very cute little mascot, I immediately jumped the Node ship, even if realistically Node was not the actual source of my problems.

The Features

Here’s a non-exhaustive list of what I consider features of my site:

Technical Features
  • autogenerated RSS, Atom, and JSON feeds

  • post content hosted on free CDN (jsDelivr)

  • free static hosting (Codeberg Pages)

  • posts automatically sorted by date, descending

  • post filters by tag

  • slug-based post URLs

Fun Features
  • favicon created by my friend

  • Rosé Pine theme

  • web version of my C.V.

  • placeholder for my project portfolio

Development

I did run into a number of problems during development, but I was able to move quickly past them once I just sat down and focused on moving forward. I think I managed to learn a lot about web development in the 8 months it took me to get Capyblogger to the state it’s in today. (Seems like a long time, but I had very little experience with web dev on my own when I started out.)

Overall, I think it’s a good foundation for a personal project. And I’m pretty proud of how I tackled the problems I encountered.

Problem 1: Nix

I wanted to use Nix to ensure reproducibility, both in my dev shell and my builds. I was trying to repurpose existing Node/NPM tooling, but in this case, Bun was not a great drop-in replacement. For example, dream2nix parses package-lock.json to discover what it needs to pull from nixpkgs. Bun only has the option to create a yarn.lock alongside its binary lockfile, so I needed to run synp on that yarn.lock to convert it to a package-lock.json. Not the worst thing, but also not what made me give up on a Nix setup for the time being.

Nixpkgs doesn’t contain Vite, so I will need to work on packaging it myself. However, I am hesitant to become a maintainer in the Nix ecosystem, considering all of the tension in the community right now. In favor of waiting to see how the dust settles, I have set aside Nix reproducibility for now.

Problem 2: Dev Environment

There’s something broken in my dev environment that causes ReferenceError [Error]: Request is not defined or ReferenceError [Error]: fetch is not defined. Currently my quick and dirty fix is to run nvm use --lts as suggested in this thread. I should probably define a proper Node dependency in my Nix flake.

Note

I have since added Node v20 in my Nix flake. I have not run into any problems like this since.

Problem 3: Accessing File Content

My original plan was to ship my blog with all the posts as part of the codebase. Because I wanted to keep hosting costs down as much as possible, I was hoping to use a free static hosting service such as GitHub or Codeberg Pages. Due to a misconception on my end, I assumed that "static" meant I needed everything in Capyblogger to be self contained and that I wouldn’t be able to fetch external resources. So I was trying get away without having to make proper HTTP requests to a server. Much to my dismay, having the files available in the codebase does not guarantee they will be available to the app in production or in the same place you expect.

Glob Import

I initially tried to use fs:readdir to read in the files from the filesystem. However, the filesystem does not exist in the browser the same way it does in your worktree, especially not with Vite and Svelte doing their magic to make your bundle as small and modular as possible.

Then I pivoted to using Vite’s glob import.

/src/routes/blog/+page.ts
export const load: PageLoad = (() => {
    const processor = Processor();
    const files = import.meta.glob('$content/**/*.adoc', {
        query: '?raw',
        import: 'default'
    });
    const titles: string[] = [];

    // read all the files in the current directory
    for (const file in files) {
        files[file]().then((content) => {
            const adoc: Document = processor.load(content as string);
            titles.push(adoc.getTitle() as string);
        })
    }

    return {
        files: files,
        titles: titles
    };
});

This made it possible to import the files asynchronously. However, the asynchrony meant that the posts were not always available right away. Plus, the posts were loaded every time with the page that needed them, which meant that they were not even loaded 100% of the time after that.

I attempted to fix this by loading all of the post files into a Svelte store, which is how Svelte handles reactive state. I created a writable store and set that to page data like what’s returned above, solving the issue of the constant asset reloading but not that of the initial lack of loaded content.

In trying to fix this bug, I went back and forth between having synchronous and asynchronous stores, the latter coming from Square’s extension of native Svelte stores. For example, my initial attempt looked like the following exported function:

/src/lib/stores/DocStore.ts
const loadDocs = async () => {
    const files = import.meta.glob('$content/**/*.adoc', {
		query: '?raw',
		import: 'default',
	});
	const docs: Document[] = [];

	// process all the files in the current directory
	for (const file in files) {
		files[file]().then((content) => {
			const adoc: Document = processor.load(content as string);
            ...
			docs.push(adoc);
		});
	}

	return docs.sort((a, b) =>
		-(Date.parse(a.getRevdate() as string) - Date.parse(b.getRevdate() as string))
	);
}

const docs = asyncWritable<Stores, Document[]>([], async () => loadDocs());

Still didn’t work. I tried to make sure that the files were loaded at the same time as the top-level global layout as below, but this didn’t quite fix the issue either.

src/routes/+layout.svelte

import { onMount } from 'svelte';
import { get } from 'svelte/store';
import docs from '$lib/stores/DocStore';

onMount(() => {
    get(docs);
})

Eventually I resigned myself to the fact that the posts were likely going to lag when launching the app in dev mode no matter what, but I also resolved not to allow that issue in production. Enter the static part of the site generator.

Adapting Statically

Switching to a static adapter meant that I could build the site on my machine and know how it would look and behave when deployed. To accomplish this, my first order of business was to change the adapter imported in svelte.config.js from 'svelte-adapter-bun' to '@sveltejs/adapter-static'. Then in src/routes/+layout.ts I set export const prerender = true;.

Prerendering dynamic blog post routes with Svelte’s static adapter means generating entries. At first, I tried to generate the entries from the slugs I put in the post docs' metadata. The fault in this approach was that the app was not fetching the docs before prerendering the dynamic routes, leading to "fetch is not defined" errors and 404s on those pages. Currently as a hotfix I fetch the docs twice: once to prerender the routes and twice to actually process them. It’s not optimal, but it works for now.

With that, here’s how I currently generate dynamic routes:

src/routes/blog/[slug]/+page.ts
export const entries: EntryGenerator = async () => {
	const version = await (await fetch(`${cdnMetaUrl}/resolved`))
		.json()
		.then((res) => res.version as string);
	const docnames = await (await fetch(`${cdnMetaUrl}@${version}`))
		.json()
		.then((res) =>
			res.files
				.filter((file: JsDelivrFile) => file.name.includes('.adoc'))
				.map((file: JsDelivrFile) => file.name)
		);
	const slugs: string[] = [];

	for (const docname of docnames) {
		slugs.push({ slug: docname.replace('.adoc', '') });
	}

	return slugs;
};

See the End(point) aka CDN

You’ll notice in the code block above that I am fetching the doc names from a CDN instead of using Vite’s glob import as I explained before.

After switching to the static adapter, I realized that keeping my content with my code no longer had the advantages that I thought it would. I thought it would simplify my releases, and I thought it was necessary to make sure I could deploy on a static site host like GitHub or Codeberg Pages. But I was stuck navigating the murky world of file access with no defined filesystem, and it was async and still working with the adapter that actually fulfilled my static needs. That meant I had more options than I thought for handling my files.

At first I considered going in the complete opposite direction. Maybe my intentions behind my approach were wrong, and the "proper" way to handle everything would be to scale up and have my content on an actual server and write proper business logic for industry beloved client-server communication.

I was dreading this. It would take what should have been a quick little project for me and snowball into what is easily a full time product. When researching what exactly a server and client would involve, I remembered that CDNs exist. They ended up fitting in really nicely with the approach I was already taking, which was a welcome surprise.

  1. I was already importing the files asynchronously, so I could keep using fetch to use a CDN.

  2. The CDN would provide a reliable server endpoint for me to use without having to keep fussing with server endpoints in my own codebase.

  3. With a CDN, I would be able to separate my content from my app, which was pretty desirable when taking versioning into account.

I decided to use JSDelivr as my CDN, using the GitHub API. To ensure I always have the latest content I’ve released, I actually programmatically fetch the latest version and use that to fetch the list of post documents. Then I loop through the list to get the content of the docs and load them into a store available to the rest of the app.

Important
The store is still not available when enumerating dynamic routes, and launching the app in dev mode still requires some time to load the docs, though not much.

Problem 4: Scoping Imported CSS Styles

I had an issue with the CSS styles where styles imported from other stylesheets were applied globally rather than scoped to their component. This meant that when I imported styles specifically for my blog posts, they were also being applied to the general site layout. Scoped CSS styling is an advertised feature of Svelte, so this was frustrating to me.

To set the scene, initially I imported the stylesheets into <script> tags because I didn’t know how to import stylesheets into CSS. This turned the styles into global styles because Svelte only scopes styles in <style> tags. My hotfix was to basically drop the contents of my css files into each component’s styles. But I wasn’t satisfied with how cluttered my component files were now, especially considering the sheer number of styles for posts.

Plugging in Imports

To properly import my stylesheets into component-scoped styles, I found the PostCSS import plugin that inlines CSS @import rules, thus ensuring my imported stylesheets are scoped to the component. This discovery is all thanks to Nkzar on Reddit for discovering this solution. Implementing the plugin was as simple as adding the postcss-import package, adding it to my Svelte preprocessing configuration as demonstrated below, and then @import-ing away as I pleased.

svelte.config.js
import atImport from 'postcss-import';
import { sveltePreprocess } from 'svelte-preprocess';

const config = {
	preprocess: [
		sveltePreprocess({
			postcss: {
				plugins: [atImport()]
			}
		})
		// vitePreprocess()
	],
	...
}

Styling {@html …​} Block

The plugin fixed how I imported styles into other components, but my blog posts were still not styled properly. I realized that because I was converting the asciidocs to HTML and using the generated HTML in my component inside an {@html} block, the scoped styles were not being applied to them. This is because {@html} blocks are injected without any Svelte processing — which is why they are dangerous to have in code that takes user input, but luckily I am the only one who I have to worry about. The lack of Svelte processing means that these blocks need global styling.

To avoid any side effects from styling post content, I first wrapped the block in its own class and then defined styles for classes within that wrapper class like this:

src/lib/components/BlogPost.svelte
<div class='blogpost'>{@html content}</div>
src/lib/styles/blogpost.css
.blogpost { (1)
	& b, (2)
	strong {
		font-weight: bold;
	}

	& abbr[title] {
		cursor: help;
		text-decoration: none;
	}

	....
}
  1. This class is at the root level.

  2. Every class within .blogpost needed the explicit nesting selector & in order to work properly.

Note
Currently syntax highlighting in code blocks is not working, as I’m sure you can see. This solution is quite complex and I am considering writing an Asciidoctor plugin to fix this later on.

Deployment

Currently, this site is hosted on Codeberg Pages on a branch of the Capyblogger repo. My process for updating the site is manual because I’ve been focusing on Capyblogger functionality and not continuous deployment. It goes a little something like this, which you can find in the repo README:

  1. Build the site: bun run build

  2. Switch to pages branch: git switch pages

  3. Clean up old files to keep prevent side effects: git ls | grep -v -e .domains -e .gitignore | xargs rm

  4. Repopulate repo with new build: cp -R build/ .

  5. Commit and push changes

It was my first time actually deploying something myself, so I struggled quite a bit getting it to work. Once I set up the static adapter and the pages branch, all I had to really do was copy the build folder to the branch. From there, I could access my new blog from https://ebarruel.codeberg.page/capyblogger/ easily.

The problem was figuring out how to set up the DNS properly.

Problem 1: DNS Woes

I bought two domains for my blog from Porkbun:

I wanted everything to redirect to ebarruel.com, including the www subdomains. The biggest issue was with the www subdomains for some reason, but here is how I was able to get everything pointing to the correct domain:

Domain CNAME ALIAS TEXT

ebarruel.com

 — 

codeberg.page

pages.capyblogger.ebarruel.codeberg.page

empbarruel.com

 — 

ebarruel.com

 — 

www.empbarruel.com

ebarruel.com

 — 

 — 

www.ebarruel.com

pages.capyblogger.ebarruel.codeberg.page

 — 

 — 

I do know that the apex domains ebarruel.com and empbarruel.com have to use ALIAS records instead of CNAME records because they are the apex domains, but to be honest, I don’t understand the fact that www.ebarruel.com fails with a "Misdirected Request: Domain not specified in .domains file" error if I point it directly to ebarruel.com. This error occurred even if I had the www subdomains in the .domains file. I could probably have used this solution to keep all my DNS at Porkbun with A records, but what I have right now works and I think it’s fine.

For good measure, here is what I currently have in my .domains file:

# ---- CUSTOM ----
ebarruel.com
empbarruel.com
www.empbarruel.com
www.ebarruel.com
# ---- CODEBERG ----
capyblogger.ebarruel.codeberg.page
pages.capyblogger.ebarruel.codeberg.page

The Future of Capyblogger

There’s always going to be work to do on this project, but here’s some of the work I have planned:

Syntax Highlighting

Currently, syntax highlighting is nonexistent on my blog. I am using Asciidoctor.js instead of Asciidoctor in Ruby, and the only out-of-the-box syntax highlighter for JS is highlighter.js. However, this runs client side, and I would rather have the highlighting done at build time.

From the precursory research I’ve done, there is a highlight.js extension for Asciidoctor.js created by Jakub Jirutka, but it hasn’t been updated in a while. I feel like I would have to gut the entire extension to be happy with it, so I’d rather use it as inspiration and make something that fits my situation.

Currently I’m eyeing Shiki to do this. I know a lot of it might be marketing, but it feels more modern than highlight.js. There is one by Taniguchi Masaya , but like asciidoctor-highlightjs, it hasn’t been updated in a couple years. Shiki also injects inline styles rather than applying classes, which feel incongruent with the structure of Capyblogger, so if I go this route I will probably employ the method David Bushell used to get around this.

Semantic HTML Converter for Asciidoctor

Asciidoctor leaves a litany of <div> tags everywhere when converting to HTML, which is not the end of the world in my opinion, but it’s not the best practice either. I want to take Jirutka’s semantic converter and update it since, like the syntax highlighter extension, it’s been a couple years since he’s worked on the converter.

Bun2Nix

This is not specifically a Capyblogger project, but I really wanted to get this project working with Nix. I am hesitant to dive right into a process so laborious and new with my inexperience with Nix, but I do think that Bun and Nix could interface together really well. I just have to understand Nix well enough to get it to work.

Conclusion

I’m quite proud of myself for having put all this together, actually. Not just building Capyblogger itself (ignoring how barebones it is), but this somewhat comprehensive writeup about it as well. Here’s to more fleshed out features and easier blog posts in the future.

a capybara holding up a glass of chocolate milk