Creating a digital garden with Github, Markdown and a Cloudflare Worker

Rather than presenting a set of polished articles, displayed in reverse chronological order, these sites act more like free form, work-in-progress wikis.
https://maggieappleton.com/garden-history

So, here it is. How I started my digital garden using Github, Markdown and a Cloudflare worker.

Reasoning

  • Markdown
  • Github
    • Content doesn't live on my machine
    • Versioned via git
    • Can edit content from an iPad if I wanted via github web editor
    • Another developer could contribute or correct my mistakes via a pull request
  • Cloudflare worker
    • Easy setup
    • Served from the edge
    • Based on web standards
    • Inexpensive

Overview

My digital garden has two repositories. The first repo contains my content such as markdown and geojson and a build process to convert the markdown to html. The build scripts are executed via a github action each time a file is committed to the repository which matches the glob */**.md. The build process also uploads the latest html generated to the Cloudflare KV store for my site. With this process, I avoid having the worker transform the markdown to html on each request.

The second repo contains my cloudflare worker which serves the html and geojson from the KV store as well as some other assets.

Processing markdown with Remark and generating HTML with Rehype

import { readFile } from "fs/promises";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import rehypeMinifyWhitespace from "rehype-minify-whitespace";
import frontmatter from "./transformers/frontmatter.js";
import processImageList from "./transformers/process-image-list.js";
import map from "./transformers/map.js";
import removeExtensions from "./transformers/remove-extensions.js";

async function convert(content) {
  const { value: html, data } = await unified()
    .use(remarkParse)
    .use(remarkFrontmatter)
    .use(frontmatter)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeRaw) // What allows raw html to work
    .use(processImageList)
    .use(map)
    .use(removeExtensions)
    .use(rehypeMinifyWhitespace)
    .use(rehypeStringify)
    .process(content);

  return { html, meta: data.meta };
}

async function process(file) {
  const content = await readFile(file);
  const { html, meta } = await convert(content);
  return { html, meta };
}

export default async (files) => {
  return await Promise.all(files.map(process));
};

Setting up a Cloudflare worker

ESBuild

This is my build.js file for the worker using ESBuild's programmatic api:

import * as esbuild from "esbuild";
import { transform as tempura } from "tempura/esbuild";

const mode = process.env.MODE?.toLowerCase() ?? "development";

console.log(`[Worker] Running esbuild in ${mode} mode`);

esbuild.build({
  entryPoints: ["./src/index.js"],
  bundle: true,
  sourcemap: true,
  format: "esm",
  conditions: ["worker"], // https://esbuild.github.io/api/#how-conditions-work
  minify: mode === "production",
  define: {
    "process.env.NODE_ENV": `"${mode}"`,
  },
  outfile: "dist/index.mjs", // .mjs is important for Cloudflare
  plugins: [
    tempura(),
  ],
});

Miniflare

Miniflare allows you to work with Cloudflare workers locally. This is my wrangler.toml with miniflare settings.

compatibility_date = "2021-11-12"
name = "beckelman-org"
type = "javascript"
workers_dev = true

kv_namespaces = [
  {binding = "CONTENT", id = "<my-id>"},
]

[build]
command = "npm run build"

[build.upload]
format = "modules"
main = "index.mjs"

[miniflare]
watch = true
build_watch_dirs = ["src", ".mf/kv/CONTENT/css"]
live_reload = true
kv_persist = true

Miniflare is started as a dev script:

"scripts": {
    "dev": "concurrently \"npm:dev:*\"",
    "dev:worker": "miniflare"
}

Environment variables

Miniflare will pickup the variables you define in a .env file automatically. When you deploy your worker to Cloudflare you will need to set the environment variables your worker expects using their wrangler cli tool.

TailwindCSS

You cannot currently setup your tailwind config as an ES Module. They have a work around though where they will pickup the config when it ends with a .cjs. Here is my tailwind.config.cjs file:

module.exports = {
  content: ["./src/**/*.{html,js}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};

I am processing it concurrently along with miniflare using the script below and writing it a location where miniflare will serve it when requested from KV storage.

"scripts": {
    "dev": "concurrently \"npm:dev:*\"",
    "dev:worker": "miniflare --kv CONTENT --kv-persist --build-command \"node ./build.js\" --watch --debug",
    "dev:tailwind": "npx tailwindcss -c ./tailwind.config.cjs -i ./src/css/site.css -o .mf/kv/CONTENT/css/site.css --watch"
}

Publishing updated CSS

After a build, the css needs to be published to the workers KV store. The command below will upload the file.

wrangler kv:key put --binding=CONTENT css/site.css ./dist/site.css --path