Marastatic: A 300-Line Static Site Generator
How and why I built a functional SSG in less than 300 lines of code.
One of the tools I liked the most when I started diving into the Go ecosystem was Hugo. Not only was it fast and easy to use, but it also had a vibrant community providing themes, tutorials, and quick bug fixes. It was also the first time I started to pay attention to SSGs and how powerful they could be; in fact, I even landed a few gigs thanks to a couple of themes I built while experimenting with it.
But after I moved on from Go to explore other ecosystems due to multiple reasons, I forgot about them. Or at least I did until last year, when I wanted to rewrite my portfolio and a single question popped in my mind: why not build one myself?
What are static site generators?
In short, an SSG is essentially a program that takes raw content in some form or another (usually Markdown), and bakes it up into individual HTML pages. Once finished, you just need to serve the generated files.
I believe Hugo is currently one of the most used ones, but typically each programming language has its own champion. In the Python world there is Pelican, and even ways to "freeze" a Django site into static files.
Why I build my own?
Because I thought it would be easy (and it was), and because lately I've grown to love building my own tools. I also wanted an excuse to deep dive a bit into the world of SSGs again, look under the hood of existing ones, and see what was really there and if I could learn anything from them.
What makes it special?
Nothing, absolutely nothing. It doesn't have a plugin system or an image optimizer. For the assets management part it literally just "copy-pastes" the entire folder.
But this is also what make it "special", at least for me. It is a single-file Python script that almost anyone can read in less than 2 minutes, figure out how it works, and customize it if they want to.
How does it works?
Before you can start using marastatic, you'll need a config.toml file. This file not only maps your content, templates, assets, and where do you want the output to be but also helps to define site-wide variables that will be accessible on all of your templates.
[site]
base_url = "https://dnlzrgz.com"
static_dir = "static"
templates_dir = "templates"
archetypes_dir = "archetypes"
content_dir = "content"
build_dir = "output"
[params]
title = "dnlzrgz"
description = "Web Designer turned Backend developer specialized in Python, Go, and scalable systems based in Spain."
[params.author]
name = "daniel"
email = "contact@dnlzrgz.com"
[params.social]
GitHub = "https://github.com/dnlzrgz"
Bluesky = "https://bsky.app/profile/dnlzrgz.bsky.social"
LinkedIn = "https://www.linkedin.com/in/dnlzrgz/"
Once the configuration is loaded, the script follows a very basic recipe:
- Scan the
contentfolder for any.mdfiles. - Build as many
Pages as needed that will hold a few attributes like the "raw content", the frontmatter, the URL at which those pages should end up in the final site, and so on. - Build a
Jinja2environment, load thetemplatesdirectory and start building. - Generate a few extra things (RSS,
sitemap.xml) and clone thestaticfolder into the output.
And that's it. All of it. In fact, you can see it yourself here:
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python=">=3.14"
# dependencies=[
# "jinja2>=3.1.6",
# "markdown>=3.10.1",
# "python-frontmatter>=1.1.0",
# "rich>=14.3.1",
# "watchfiles>=1.1.1",
# ]
# ///
import argparse
import shutil
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from shutil import copytree, ignore_patterns
from time import perf_counter
from typing import Any
import frontmatter
import markdown
import tomllib
from jinja2 import Environment as Jinja2Environment
from jinja2 import FileSystemLoader
from rich.console import Console
from watchfiles import watch
console = Console()
MARKDOWN_CONVERTER = markdown.Markdown(extensions=["fenced_code", "tables", "abbr"])
@dataclass(slots=True, frozen=True)
class Config:
base_url: str
static_dir: Path
templates_dir: Path
content_dir: Path
build_dir: Path
params: dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
for field_name in ["static_dir", "templates_dir", "content_dir"]:
path = getattr(self, field_name)
if not path.exists() or not path.is_dir():
raise FileNotFoundError(
f"{field_name} '{path}' is not a valid directory."
)
self.build_dir.mkdir(parents=True, exist_ok=True)
@dataclass(slots=True, frozen=True)
class Page:
rel_path: Path
metadata: dict
raw_content: str
url: str
dest_path: Path
@property
def parent(self) -> str:
return self.rel_path.parent.name or "root"
@property
def content(self) -> str:
return MARKDOWN_CONVERTER.reset().convert(self.raw_content)
def list_content(path: Path) -> list[Path]:
return [f.relative_to(path) for f in path.rglob("*.md")]
def load_config(config_file: Path) -> Config:
if not config_file.exists():
raise FileNotFoundError(f"Config file {config_file} not found.")
with config_file.open("rb") as f:
data = tomllib.load(f)
site_data = data.get("site", {})
return Config(
base_url=site_data["base_url"],
static_dir=Path(site_data["static_dir"]),
templates_dir=Path(site_data["templates_dir"]),
content_dir=Path(site_data["content_dir"]),
build_dir=Path(site_data["build_dir"]),
params=data.get("params", {}),
)
def copy_static_files(config: Config) -> None:
content_dir = config.content_dir
output_dir = config.build_dir
static_dir = config.static_dir
copytree(
content_dir,
output_dir,
ignore=ignore_patterns("*.md", "*.xml"),
dirs_exist_ok=True,
)
console.print(
f"[green bold]Ok[/]: Cloned non-markdown files to '{output_dir.name}'"
)
copytree(
static_dir,
output_dir.joinpath(static_dir.name),
dirs_exist_ok=True,
)
console.print(
f"[green bold]Ok[/]: Cloned static folder '{static_dir.name}' into '{output_dir.name}'"
)
def get_url(base_url: str, rel_path: Path) -> str:
path_str = rel_path.with_suffix(".html").as_posix()
return f"{base_url.rstrip('/')}/{path_str}"
def get_all_pages(config: Config) -> list[Page]:
pages = []
for rel_path in list_content(config.content_dir):
abs_path = config.content_dir / rel_path
source = frontmatter.load(abs_path)
page = Page(
rel_path=rel_path,
metadata=source.metadata,
raw_content=source.content,
url=get_url(config.base_url, rel_path),
dest_path=config.build_dir / rel_path.with_suffix(".html"),
)
pages.append(page)
return pages
def prepare_jinja_env(
config: Config, pages: list[Page]
) -> tuple[Jinja2Environment, dict[str, list[Page]]]:
jinja_env = Jinja2Environment(loader=FileSystemLoader(config.templates_dir))
sections = defaultdict(list)
for page in pages:
if page.rel_path.stem != "index":
sections[page.parent].append(page)
jinja_env.globals.update(
config=config,
pages=pages,
sections=sections,
now=datetime.now(),
)
return jinja_env, sections
def generate_rss_feeds(
config: Config, jinja_env: Jinja2Environment, sections: dict[str, list[Page]]
) -> None:
for section_name, pages in sections.items():
if section_name == "root":
continue
try:
rss_template = jinja_env.get_template(f"{section_name}/rss.xml")
rss_path = config.build_dir / section_name / "rss.xml"
rss_path.parent.mkdir(parents=True, exist_ok=True)
rss_path.write_text(
rss_template.render(pages=pages),
encoding="utf-8",
)
console.print(f"[green bold]Ok[/]: Created RSS feed for '{section_name}'")
except Exception as e:
console.print(
f"[yellow bold]Warn[/]: No rss feed template found for '{section_name}': {e}"
)
def generate_sitemap(config: Config, jinja_env: Jinja2Environment) -> None:
try:
sitemap = jinja_env.get_template("sitemap.xml").render()
(config.build_dir / "sitemap.xml").write_text(sitemap, encoding="utf-8")
console.print("[green bold]Ok[/]: Created sitemap.xml")
except Exception as e:
console.print(f"[yellow bold]Warn[/]: No sitemap.xml template found: {e}")
def generate_robots(config: Config, jinja_env: Jinja2Environment) -> None:
try:
robots = jinja_env.get_template("robots.txt").render()
(config.build_dir / "robots.txt").write_text(robots, encoding="utf-8")
console.print("[green bold]Ok[/]: Created robots.txt")
except Exception as e:
console.print(f"[yellow bold]Warn[/]: No robots.txt template found: {e}")
def generate_pages(jinja_env: Jinja2Environment, pages: list[Page]) -> None:
for page in pages:
template_names = [
page.rel_path.with_suffix(".html").as_posix(),
f"{page.parent}/single.html",
"single.html",
]
try:
template = jinja_env.get_or_select_template(template_names)
output = template.render(page=page)
page.dest_path.parent.mkdir(parents=True, exist_ok=True)
page.dest_path.write_text(output, encoding="utf-8")
console.print(f"[bold green]Ok[/]: Rendered '{page.url}' successfully")
except Exception as e:
console.print(
f"[red bold]Err[/]: No template found for '{page.rel_path}': {e}"
)
def clean(build_dir: Path) -> None:
console.print(f"๐งน Cleaning '{build_dir.name}'...")
shutil.rmtree(build_dir)
build_dir.mkdir(parents=True, exist_ok=True)
console.print(f"๐งน Cleaned '{build_dir.name}'!")
def build(config: Config) -> None:
start_time = perf_counter()
console.print(f"โจ Building {config.base_url}")
console.print("๐ฑ Scanning content directory...")
pages = get_all_pages(config)
console.print(f"[bold green]Ok[/]: {len(pages)} pages found!")
console.print("๐งฐ Preparing Jinja2 environment...")
jinja_env, sections = prepare_jinja_env(config, pages)
console.print("๐จ๏ธ Rendering pages...")
generate_pages(jinja_env, pages)
console.print("๐ก Generating RSS feeds, Sitemap and robots.txt...")
generate_rss_feeds(config, jinja_env, sections)
generate_sitemap(config, jinja_env)
generate_robots(config, jinja_env)
console.print("๐ฆ Copying static files...")
copy_static_files(config)
end_time = perf_counter()
duration = end_time - start_time
console.print(f"๐ Build complete in {duration:.2f}s!")
def watch_and_rebuild(config: Config) -> None:
console.print("๐ก Watching for changes...")
paths = [config.content_dir, config.templates_dir, config.static_dir]
for _ in watch(*paths):
console.print("๐ก Changes detected! Rebuilding...")
build(config)
def main():
parser = argparse.ArgumentParser(
prog="marastatic",
description="Single-file static site generator.",
)
parser.add_argument("--config-file", type=Path, default="config.toml")
parser.add_argument("--watch", action="store_true")
parser.add_argument("--clean", action="store_true")
args = parser.parse_args()
try:
config = load_config(args.config_file)
if args.clean:
clean(config.build_dir)
build(config)
if args.watch:
watch_and_rebuild(config)
except KeyboardInterrupt:
console.print("๐๐ป bye!")
except Exception as e:
console.print(f"[bold red]Err[/]: {e}")
if __name__ == "__main__":
main()
Why less than 300 lines of code?
I though it would be funny to try to do it in the simplest way possible and a line limit sounded good. As you can see the script is not really optimized and I let out a lot of functionality. In the future I may try a bit harder and squeeze things like a live server.
Final notes
If you look at the example in the repository you'll notice that the config.toml is a bit different than the one I am showing in this post and that's because I changed the "original" script a bit to fit better the idea I had for this site. That's also a reason why I didn't try to go overboard with the complexity. As it is now the script is really easy to modify and adapt. And again, if I ever need anything more complicated moving on should be fairly easy.
Also, I decided to use uv because is the tool I am using now for almost anything that has to do with Python.