Pylon: A CLI-aware Static Site Generator

A few years ago, I found myself in a familiar situation for many developers: I needed a static site generator that could handle my specific workflow, but the existing options just didn't quite fit. Tools like Jekyll, Hugo, and Eleventy are fantastic, but they often come with opinions about how things should be structured or limited flexibility when it comes to integrating with other CLI tools. I wanted something that could seamlessly incorporate my existing build processes. This included things like Sass compilation, image optimization, or even custom scripts.

That's when I decided to build Pylon, my own static site generator written in Rust. In this post, I'll share a little bit about Pylon, and its key features.

What is Pylon?

Pylon is a static site generator designed for flexibility and integration with existing CLI tooling. At its core, it transforms Markdown files into HTML using Tera templates, while allowing you to script custom build processes with Rhai, a lightweight scripting language embedded in Rust.

The project is structured as a Rust workspace with several crates (along with the application crate):

  • pylonlib: The main library containing the core engine, rendering logic, and dev server.
  • typed_path, typed_uri, and pathmarker: For type-safe path and URI handling.
  • pipeworks: Manages the pipeline system for asset generation.
  • shortcode_processor: Handles custom shortcodes in Markdown.

Why Another Static Site Generator?

Having used various static site generators over the years, I kept running into similar limitations. Most tools expect you to structure your content or build process in specific ways. Want to use a different CSS preprocessor that's not natively supported? If you're lucky someone already wrote a huge script to make it work. And that's assuming it's even possible to integrate. Want to just cat in a block of code directly into a pre tag? Good luck.

I wanted a tool that embraced the CLI ecosystem rather than replacing it. If I can run a command in my terminal, I should be able to integrate it into my site build. No plugin systems to learn, no configuration formats to memorize for each tool. Just plain shell commands orchestrated by a clean, scriptable interface.

This philosophy led directly to Pylon's core design decision: orchestrating file generation on-demand via CLI commands. This naturally required some form of scripting language for maximum flexibility, so I chose Rhai since it's built specifically for Rust.

Key Features of Pylon

Pipelines: On-Demand Asset Generation

One of Pylon's standout features is its pipeline system. Instead of pre-processing all assets, Pylon generates them on-demand when they're referenced in HTML. Here's how it works:

          ╭───────────────╮
          │               │
          │  Copy static  │
          │  assets       │
          │               │
          └───────┬───────╯
                  │
                  ▼
          ╭───────────────╮
          │               │
          │ Generate HTML │
          │               │
          ╰───────┬───────╯
                  │
                  ▼
          ╭───────────────╮
          │               │
          │  Find links   │
          │               │
          ╰───────┬───────╯
                  │
                  ▼
          ╭───────────────╮
          │               │
          │ Build missing │
          │ files list    │
          │               │
          ╰───────┬───────╯
                  │
                  ▼
          ╭───────────────╮
          │               │
          │ Run pipelines │
          │ to generate   │
          │ missing files │
          │               │
          ╰───────────────╯

Pipelines were designed to allow integration of any tool that can be ran from CLI, making it easy to use whichever tooling you need to generate your site. Let's take a look at an example of how it works.

This example uses shell redirection and the $TARGET token to generate a site's CSS using the Sass preprocessor.

Given this source directory structure:

web/
└── styles/
    ├── a.scss
    ├── b.scss
    ├── c.scss
    └── main.scss  # imports a, b, c

and a desired output directory of

output/
├── index.html     (containing <link href="/style.css" rel="stylesheet">)
└── style.css      (derived because index.html links to it)

we can use this pipeline to generate the missing style.css file:

rules.add_pipeline(
  "/web/styles",                // Working directory (source)
  "/style.css",                 // Target file
  [
    "sass main.scss > $TARGET"  // Sass command (gets run in CLI)
  ]
);

This will result in the CSS file being generated by Sass and saved to /output/style.css.

The $TARGET token gets replaced with the output path, and Pylon only runs this when /style.css is linked in your HTML. This lazy evaluation integrates perfectly with tools like Tailwind, which need to scan your HTML first. Since Pylon spawns a shell, you can run any arbitrary scripts or commands to manipulate the generated site.

Lints

One thing I find missing from all static site generators is the ability to lint the site. I want to know if I forget something, but there's not really any way to do that in the existing generators. So of course, I added lints to Pylon!

Prior to building the site, Pylon can check the Markdown documents for issues that you specify. There are two lint modes available:

  • WARN will log a warning during the build
  • DENY will log an error and cancel the build

Lints are defined in Rhai just like pipelines:

rules.add_lint(
  MODE,       // either WARN or DENY
  "",         // the message to be displayed if this lint is triggered
  "",         // a globbing pattern for matching Markdown documents 
  |doc| {}    // a closure that returns `true` when the lint fails, and `false` if it passes
);

Here's an example of a lint that emits a warning if a blog post does not have an author:

rules.add_lint(WARN, "Missing author", "/blog/**/*.md", |doc| {
  // We check the `author` field in the metadata and ensure it is not blank,
  // and we also check if the `author` field exists at all. If the `author` field
  // is missing, its type is a unit `()`.
  doc.meta("author") == "" || type_of(doc.meta("author")) == "()"
});

Shortcodes for Rich Content

Pylon supports shortcodes which are custom template functions that can be used directly in Markdown. There are two types: inline (for simple arguments) and body (for multi-line content).

<!-- templates/shortcodes/dialog.tera -->
<div class="dialog">
  <h1>{{ heading }}</h1>
  <p>{{ body }}</p>
</div>
{% dialog(heading = "Alert") %}
This is some **important** information that will be rendered as Markdown.
{% end %}

This makes it easy to create reusable components without cluttering your Markdown.

In addition to defining Markdown and HTML shortcodes, it's possible to capture the output of arbitrary commands and insert it into the page. This is done using the include_cmd shortcode which enables you to use any CLI program as a data source to populate information on a page.

Here's an example of inserting code from a file directly into the page:

<pre>
    <code data-lang="rust">
        {{ include_cmd(cwd = "/content/my_post/", cmd = "cat sample_code.rs" ) }}
    </code>
</pre>

Pylon also provides the $SCRATCH token as a stand-in for file output. So if some tool only outputs to file (which is common for JS tooling), you can still use it.

Dev Server with Live Reload

The built-in dev server watches your content, templates, and configuration files, automatically rebuilding and refreshing the browser. Configuration is done via Rhai:

// watch a file
rules.watch("package.json");

// watch a directory
rules.watch("static");

Type-Safe Path & URI Handling

Since Pylon deals heavily with paths, I created the typed_path and typed_uri crates to provide compile-time guarantees about paths. They distinguish between absolute and relative paths, preventing common errors. This made it simple to perform all the necessary path manipulation between managing file paths and URIs.

It's actually pretty simple:

let abs_path = AbsPath::new("/home/user/file.txt")?;
let rel_path = RelPath::new("content/index.md")?;

Any path manipulation that results in a change between relative path and absolute path also results in a change in type. So there's never a question whether you have a relative or absolute path in the code.

Conclusion

Building Pylon has been an incredible learning experience. It taught me about static site generation, embedded scripting, and the nuances of CLI tool design. More importantly, it scratched my itch for a flexible, integrated build tool.



Comments