🌵Cacty: Hugo/Jekyll in 90 lines

Posted on 14 Aug 2024 by Claudio Santini

I was trying to get this blog started and I wanted to use Hugo. But I found it too complicated: I had to learn where to put the template files, how the sytnax worked.

In the time it would have taken me to learn where Jekyll / Hugo want you to put your files, I wrote a simple static site generator in Python. It uses Jinja2 for templating and Markdown for content. It's just HTML and Markdown, and the best part is that you can easily understand how it works and modify it to your needs, since it's only 90 lines of code.

How to use it

The file structure is simple:

/ 
    /build
        ...all the HTML files generated by Cacty
    /static
        ...your CSS, JS, images, etc.
    /templates
        _base.html
        _analytics.html
        index.html
        post.html
        posts.html
    /posts
        first-blog-post.md
        second-post.md
    cacty.py

Cacty reads any file like /templates/x.html and creates a web page called x.html. It will ignore any template file that starts with an underscore _, so you can use it for partial templates. For example /templates/about.html will be a web page, but templates/_footer.html will not. As it's usually done in Django, you can then use {% extends "templates/_base.html" %} in your templates to have a base template and have a consistent look and feel across your site.

To run Cacty, just run python cacty.py -w. Adding -w it will watch for changes in the /templates and /posts directories, and it will run a local http webserver and automatically open your browser at any file change.

That's all you need to know.

Deploy to your server

To deploy your site, just copy the build/ directory to your webserver. I use rsync for that:

#!/bin/sh
rsync -az build claudio.uk:/srv/sites/claudio/

Source code

This is the code for cacty.py:

"""Claudio's static site generator: https://claudio.uk/posts/cacty.html"""
import os, sys, markdown, time, datetime, http.server, socketserver
import markdown.extensions.fenced_code
import markdown.extensions.tables
from jinja2 import Environment, FileSystemLoader, select_autoescape
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler


def build():
    os.system('mkdir -p build/posts')
    jinja = Environment(loader=FileSystemLoader('.'), autoescape=select_autoescape())

    # Build all the markdown files from posts/x.md to build/posts/x.html,
    # using as base template/post.html
    posts = []
    for filename in os.listdir('posts'):
        with open(os.path.join('posts', filename), 'r') as md_file:
            md = markdown.Markdown(extensions=['fenced_code', 'tables', 'meta'])
            output_filename = filename.replace('.md', '.html')
            print(f'building post {output_filename}')
            post_html = md.convert(md_file.read())
            template = jinja.get_template('templates/post.html')
            meta = {k: v[0] for k, v in md.Meta.items()}
            meta['date'] = datetime.datetime.strptime(meta['date'], '%Y-%m-%d')
            if 'draft' in meta and meta['draft'].lower() == 'true': continue
            page_html = template.render(post=dict(html=post_html, **meta))
            with open(os.path.join('build', 'posts', output_filename), 'w') as output_file:
                output_file.write(page_html)
            page_name = filename.replace('.md', '.html')
            posts.append(dict(filename=filename, href=f'posts/{page_name}', **meta))
    posts = sorted(posts, key=lambda post: post['date'], reverse=True)

    # Build all templates/x.html, excluding those starting with an underscore (partials)
    for filename in os.listdir('templates'):
        if filename.split('.')[-1] not in ('html', 'xml') or filename.startswith('_') or filename == 'post.html':
            continue
        template_path = os.path.join('templates', filename)
        with open(template_path, 'r') as template_file:
            print(f'building page {filename}')
            template = jinja.get_template(template_path)
            html = template.render(posts=posts)
            with open(os.path.join('build', filename), 'w') as output_file:
                output_file.write(html)

    # Copy static/ into build/static
    os.system('mkdir -p build/static')
    os.system('cp -r static/* build/static/')
    print('build complete\n')


def watch_files():
    # If any file changes in templates/ posts/ or static/, rebuild the site.
    class Handler(FileSystemEventHandler):
        def __init__(self):
            super().__init__()
            self.last_run = time.time()

        def on_any_event(self, event):
            if time.time() - self.last_run < 1:
                return
            print(f'{event.src_path} changed, rebuilding')
            build()
            self.last_run = time.time()

    event_handler = Handler()
    observer = Observer()
    observer.schedule(event_handler, '.', recursive=True)
    print('watching files')
    observer.start()


def start_http_server():
    class Handler(http.server.SimpleHTTPRequestHandler):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, directory='build', **kwargs)

    try:
        socketserver.TCPServer.allow_reuse_address = True
        with socketserver.TCPServer(("", 8123), Handler) as httpd:
            print("serving at http://localhost:8123")
            httpd.serve_forever()
    except KeyboardInterrupt as exc:
        httpd.shutdown()
        httpd.server_close()
        raise


if __name__ == '__main__':
    build()
    if '-w' in sys.argv:
        watch_files()
        start_http_server()