3 November 2023

Building content-heavy sites with SvelteKit and markdown

Here are some tips and techniques I’ve developed for building blogs and other content-driven sites with SvelteKit and markdown.

The basic technical elements of the approach – e.g. how to get SvelteKit to dynamically list and render blog posts from a directory – are all originally based on Josh Collinsworth’s excellent article and tutorial on building a static markdown blog with SvelteKit.

I recommend following along with the tutorial to get a feel for the techniques.

Once you have the basic technological framework in place, there are some best practices I recommend to make organising your pages and authoring content as easy as possible.

Minimise authoring friction

Do not tie yourself to a particular folder structure or naming convention. It’s tempting to try to reach a neat, single-source-of-truth structure where some combination of folder structure, filename, and metadata perfectly defines where a page appears and what it looks like. The problem is that this will lead to endless bikeshedding and wrestling with whatever system you end up creating when new content doesn’t quite fit the mold. Instead, opt for maximum flexibility, some repetition/redundancy, and support the simplest possible piece of content – a markdown file in src/pages with no metadata.

Do not automatically generate navigation menus

This seems like it would be a nice feature to have, but in my experience the authoring friction it introduces massively outweighs the benefits in DRY-ness and automation.

Keep it flat

If you have multiple categories, resist the temptation to put pages in strictly-defined folders. You will have content that spans multiple categories, and content that you just want to drop in src/pages to get it on the internet.

Infer details

If you do have multiple categories, you can use top-level folders as general indicators of which category a piece of content might belong to. If a page of mine is in src/pages/misophonia, for example, its category defaults to misophonia unless overridden in the metadata.

Similarly for the other attributes:

  • The title defaults to the filename.

  • The link label defaults to the title.

  • The id defaults to the filename.

  • The slug defaults to a slugified version of the id.

  • Things like page layout and whether to display a sidebar are inferred from the filename and location, again unless overridden. In general, .md files are likely to be straight-forward text, so we wrap them in a text column by default, while .svelte files are likely to want to define this themselves. Both can be overridden.

Be permissive

My pages have an optional slug property to define the URL. Since we know this will be a slug, there is no need to require proper slug format in the metadata. I can write it as slug: build content driven sites svelte markdown, which is easier to type than a bunch of dashes, and the code converts it for me.

Everything is a page

It’s not necessary to strictly differentiate between pages and posts with the folder structure or naming. Again, a simple convention of inferring based on attributes allows maximum flexibility: a page with a date defaults to being a post, and this can be overridden in the metadata.

The core of the CMS logic in this site is src/pages.js. This file uses import.meta.glob to gather all the pages up, process them, and provides methods for querying them. The processing is basically just inferring attributes.

Here it is, as of writing:

import slugify from "slugify";
import mapArrayToObject from "$utils/mapArrayToObject";
import cats from "$lib/cats";

let files = import.meta.glob(["/src/pages/**/*.md", "/src/pages/**/*.svelte"], {eager: true});

let pages = Object.entries(files).map(function([_path, page]) {
	let {metadata: meta = {}, default: content} = page;
	
	let [ext] = _path.match(/.w+$/);
	let type = ext.substr(1);
	let path = _path.slice("/src/pages".length, -ext.length);
	
	/*
	figure out details from filename etc, with overrides in meta
	
	e.g. id defaults to basename; slug defaults to id; link defaults to title
	
	by default, md files are displayed in a col (automatically wrapped
	in <div class="col">) and svelte files are not - assuming svelte
	files more likely to want custom markup
	*/
	
	let name = path.split("/").at(-1);
	let s = meta.s || "";
	let id = meta.id || name;
	let slug = slugify(meta.slug || id);
	let link = meta.link || meta.title;
	let prefix = s ? "/" + s : "";
	let href = "/p" + prefix + "/" + slug;
	let catId = "etc";
	let col = ("col" in meta) ? meta.col : type === "md";
	let large = ("large" in meta) ? meta.large : id.endsWith(" index");
	
	if (s.startsWith("exp") || path.startsWith("/expansion/")) {
		catId = "expansion";
	} else if (s.startsWith("miso") || path.startsWith("/misophonia/")) {
		catId = "misophonia";
	}
	
	let cat = cats[catId];
	
	return {
		...meta,
		cat,
		id,
		slug,
		path,
		href,
		link,
		col,
		large,
		content,
	};
});

let allPosts = pages.filter(page => page.date).sort((a, b) => new Date(b.date) - new Date(a.date));

let posts = allPosts.filter(post => !post.draft);
let drafts = allPosts.filter(post => post.draft);

let byId = mapArrayToObject(pages, page => [page.id, page]);
let bySlug = mapArrayToObject(pages, page => [page.slug, page]);

export default {
	all() {
		return pages;
	},
	
	byId(id) {
		return byId[id];
	},
	
	bySlug(slug) {
		return bySlug[slug];
	},
	
	byCat(catId) {
		return pages.filter(page => page.cat.id === catId);
	},
	
	latestPost() {
		return posts[0];
	},
	
	link(id) {
		return byId[id].href;
	},
	
	cats,
};

Drawbacks

Svelte is not without its trade-offs as a platform for content-heavy sites. Primary among them is verbosity: there is no native support in Svelte for concise Wikipedia-like macros for things like links to other pages. Instead, you have to explicitly import a Link component and do:

<Link to="misophonia index"/>

Additionally, the Link component will not inherit styles from the parent component, so these must be defined in a global CSS file.

Conclusion

The main thing I miss in Svelte in general is the ability to style nested Svelte components from the parent. This is particularly felt when you want to write a lot of content quickly. It forces you over into “programming” space, whereas the rest of the stack pretty much allows you to stay in “writing” space. On balance, though, I think the trade-offs are worth it, and the overall flexibility of the setup means there’s always a way to get things working more or less ergonomically.