🌵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()