enclume
Hit your content on the anvil until a website comes out.
License: The MIT License (MIT)
Enclume
Enclume (/ɑ̃klym/ · french noun for anvil) is a simple static site generator built for simplicity and flexibility.
I built Enclume after years of using Astro for my personal website. I was getting annoyed at the amount of configuration and indirection layers needed for a simple blog, and I yearned for the simpler days of editing source from /var/www via FTP. So with Enclume, I said no more complicated config, no more centralized variables, we are bringing the static in static site generator, while keeping the build step for templating and parsing content.
Quickstart
Enclume can be installed from source (requires the Rust toolchain installed)
cargo install enclume --lockedSome functionalities are gated behind a crate feature:
dev(enabled by default): enables the commandenclume devto serve the built folder locally while watching the source for rebuilding. If you need to build a website non-interactively, use the--no-default-featuresflag to get a lighter build.fontawesome(disabled by default): unlocks the fontawesome icons in Markdown parsing viarushdown-icons. Since fontawesome has a stricter license than this project, this is opt-in only.
How does it work?
To live up to its purpose, Enclume needs to adapt to all sorts of website structures while adding as little complexity as possible. This is a minimal website you can build right now
src/
globals.yaml
templates/
layout.html
post.html
root/
index.html
style.css
blog/
post.md
other_post.htmlor just use enclume init to scaffold something similar.
To generate the matching static website, you can use
enclume build --base src --out distA dev command is also available which serves the built website on localhost and rebuilds when the source changes.
Parsing
Enclume provides four parsers: Markdown (.md), HTML (.html), JSON (.json) and YAML (.yaml). The program walks your root/ folder and parses files along the following logic:
- if the file matches one of the parsers and it has a frontmatter, send it to the parser,
- otherwise, copy the file as-is.
So parseable files are determined by the presence of a YAML frontmatter of the kind
---
template: "post.html"
vars:
title: "This is a blog post"
---
Some contentThe frontmatter should include the following keys:
template: path to the template file, relative tosrc/templates/. Required.output: path to the output file (including extension), relative todist/. Optional.vars: a nested object passed to the template.
Note: The output field is particularly useful for non-HTML output like rss.xml or sitemap.xml.
Templates
Templates are built using the Tera template engine. The following variables and functions are provided to the templating engine and can be used in your templates:
vars: local variables defined in the frontmatterglobals: global variables fromsrc/globals.yaml,content: the rendered content from the page body (HTML for Markdown and HTML parsers, objects for YAML and JSON),url: URL of the page (relative to the domain),toc: List of(level, id, title)for each heading in the page (Markdown parser only),asset( path = "...: ): get theurlof a given source file,pages( pattern = ".." ): get all the built pages following a given pattern.
For example, this could be the layout
<!-- layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
{% block head %}
<link rel="stylesheet" href="{{ asset(path=\"style.css\") }}" />
<title>{% block title %}{% endblock title %} - My Webpage</title>
{% endblock head %}
</head>
<body>
<div id="content">{% block content %}{% endblock content %}</div>
</body>
</html>and the blog post template
<!-- post.html -->
{% extends "base.html" %}
{% block title %}{{vars.title}}{% endblock title %}
{% block content %}
{{content}}
{% endblock content %}Pages are built with clean URLs by default: about.md becomes about/index.html. Pass --no-clean-urls to build about.html instead.
Pass --hash-assets to add a content hash to the name of every css and js file (style.css becomes style.<hash>.css), which asset() resolves. This
Site-wide variables
The optional src/globals.yaml is meant for site-wide variables
# globals.yaml
title: "My Webpage"
base_url: "https://example.com"
author: "Jane Doe"Every template reads them under globals:
<title>{{ globals.title }}</title>
<a rel="author" href="/about">{{ globals.author }}</a>Listing other pages
A template cannot read the filesystem, but it can list the pages the build parsed. The pages() function takes a glob pattern, relative to root/ and returns the matching pages in source-path order:
<!-- blog-list.html -->
<ul>
{% for post in pages(pattern="blog/**/*.md") %}
{% if post.url != __url__ %}
<li><a href="{{ post.url }}">{{ post.vars.title }}</a> ({{ post.vars.publishDate }})</li>
{% endif %}
{% endfor %}
</ul>Each entry holds path (the source file), url (where it is served, clean URLs included) and vars (everything the file declared under vars).
Sitemap and feeds
Sitemap and feeds need to be mapped manually using the output variable
<!-- root/sitemap.md -->
---
template: sitemap.xml
output: sitemap.xml
---<!-- templates/sitemap.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for page in pages(pattern="blog/**/*.md") %}
<url><loc>{{ globals.base_url }}{{ page.url }}</loc></url>
{% endfor %}
</urlset>Table of contents
The headings of a Markdown page are collected into a toc variable passed to the template
{% if toc %}
<nav class="toc">
<ul>
{% for heading in toc %}
<li class="level-{{ heading.level }}"><a href="#{{ heading.id }}">{{ heading.title }}</a></li>
{% endfor %}
</ul>
</nav>
{% endif %}Each entry holds the level of its heading (1 for #, 2 for ##, and so on), the id a link to it ends with, and the title of the heading with its inline markup flattened.
Math
Markdown content can hold LaTeX formulas. They are typeset while the site is built, by a Rust port of KaTeX, into the markup KaTeX itself produces: HTML, plus a MathML copy of every formula for screen readers and for text selection. No JavaScript runs in the browser.
Inline math is written between single dollars, like $E = mc^2$.
Display math is written between double dollars, on a line of its own:
$$
\int_0^1 x \, dx = \frac{1}{2}
$$
or as a `math` block:
```math
\int_0^1 x \, dx = \frac{1}{2}
```Icons
An icon is a namespace and a name between double colons. It renders as an inline <svg> which takes the size and the colour of the text around it, so prose needs no stylesheet:
Press ::lucide:arrow-left:: to go back, or see the branch history with ::octicon:git-branch::.Three sets are always available by default:
- Lucide as
lucide, - Octicons as
octicon, - and Simple Icons as
si.
An Octicon may be named without its size, so ::octicon:git-branch:: and ::octicon:git-branch-24:: draw the same icon. Simple Icons are named as the brand publishes them, so ::si:rust:: and ::si:Rust:: both work.
Font Awesome Free sits behind the fontawesome feature, which is off by default: cargo build --features fontawesome enables ::fa:rocket::, ::fa-solid:rocket::, ::fa-regular:bell:: and ::fa-brands:github::.
AI usage
I used AI as an assistant: review what I did, give me some ideas on how to implement stuff, and for clear and simple task, do it and I'll review. But the core decisions and initial project build was all human.