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:

  1. Scan the content folder for any .md files.
  2. 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.
  3. Build a Jinja2 environment, load the templates directory and start building.
  4. Generate a few extra things (RSS, sitemap.xml) and clone the static folder 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.

Marastatic: A 300-Line Static Site Generator