Functional functional static site generation

See ssgen.py for an implementation of this post.

I've never been quite happy with any static site generator (SSG). It sucks to have to worry about version upgrades, leave my website to the whims of a VC-backed SSG, and sift through a pile of unrelated docs I don't need. Writing HTML by hand isn't great either, as most websites have a lot of duplication (layout, templates) and aggregation (index pages) going on.

Let's go back to the basics. At its core, a static site generator takes a file and transforms it. For example, a Markdown input is transformed into HTML.

However, this precludes path-dependent content such as per-page styles and path-dependent <meta>, so we also want to pass the current path of the file. In curried form:

This is still incomplete, as can only depend on a single file. This means we cannot generate index pages or lists of pages. To fix this, we introduce the notion of a context, which represents external information such as the filesystem, the current time, and the internet.

This works out well if the input path is used as the output path. However, this is often not the case. For example, we'll want to perform routing to output page/index.html from page.md. So we return a pair of a transformed path and a file transformation function:

In reality, the SSG would consist of multiple rules, say , with the same type as above. Each rule performs small independent transformations, and may be chained together. Only the rules need to be provided by the user. The chaining and calling of the rules are handled by the SSG library, being reusable across sites. Together, the user-provided rules and the SSG library form a complete SSG. For example, in Python, the uncurried types would look like the following:

type Transform = Callable[[Contents], Contents]
type Rule = Callable[[Path], tuple[Path, Transform] | None]

Then, the user only needs to worry about providing rules of the correct type:

# User Code

def bundle(path: Path, data: RuleResult):
    if path != Path("index.js"):
        if path.suffix in (".js", ".ts"):
            return None
        return path, lambda x: x
    return path.with_suffix(".js"), call_bundler

def minify(path: Path, data: RuleResult):
    if (ext := path.suffix) not in (".js", ".css"):
        return path, lambda x: x
    return path.with_suffix(f".min{ext}"), call_minifier

rules: list[Rule] = [bundle, minify]

Then, the SSG library can handle the calling of the rules. For a static build, this would be a simple map operation over all files, and for a development server, each request would trigger a single evaluation of the rules. Conceptually, for a single file:

# SSG library Code
          
def process(path: Path, contents: Contents):
    transforms = []

    for rule in rules:
        result = rule(path)
        if result is None:
            return None
        path, transform = result
        transforms.append(transform)

    for transform in transforms:
        contents = transform(contents)

    return path, contents

The above design works particularly well with LLMs. The SSG library portion can provide a small and stable API (ssgen.py), which the LLMs can write the tedious site-specific rules against without affecting core site building functionality.