devsite-builder
blog.py
#!/usr/bin/env python3
import os
import html
import shutil
import argparse
from dataclasses import dataclass
from datetime import datetime
from collections import defaultdict
from siteutils import load_template, render, markdown
POST_DIR = "blog/posts"
OUTPUT_DIR = "www/blog"
STATIC_DIR = "static"
# ------------------------------------------------------------
# Post
# ------------------------------------------------------------
@dataclass
class Post:
title: str
date: datetime
tags: list
slug: str
content: str
source: str
@property
def url(self):
return f"/blog/{self.slug}.html"
@property
def date_iso(self):
return self.date.strftime("%Y-%m-%d")
@property
def date_human(self):
return self.date.strftime("%d %B %Y")
# ------------------------------------------------------------
# Post loading
# ------------------------------------------------------------
def parse_frontmatter(text):
if not text.startswith("---"):
raise ValueError("missing front matter")
end = text.find("\n---", 3)
if end == -1:
raise ValueError("unterminated front matter")
raw = text[3:end].strip()
# The closing delimiter may have extra trailing dashes
# (e.g. "----------"), so skip past the whole line.
line_end = text.find("\n", end + 1)
if line_end == -1:
body = ""
else:
body = text[line_end + 1:].strip()
metadata = {}
for line in raw.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key == "tags":
value = value.strip("[]")
value = [
x.strip()
for x in value.split(",")
if x.strip()
]
metadata[key] = value
return metadata, body
def load_post(path):
with open(path, "r", encoding="utf-8") as f:
text = f.read()
metadata, body = parse_frontmatter(text)
filename = os.path.basename(path)
slug = os.path.splitext(filename)[0]
post_date = datetime.strptime(
metadata["date"],
"%Y-%m-%d"
)
return Post(
title=metadata["title"],
date=post_date,
tags=metadata.get("tags", []),
slug=slug,
content=markdown(body),
source=path
)
# ------------------------------------------------------------
# Blog
# ------------------------------------------------------------
class Blog:
def __init__(self):
self.posts = []
def load(self):
if not os.path.isdir(POST_DIR):
raise RuntimeError(
f"post directory does not exist: {POST_DIR}"
)
self.posts = []
for filename in os.listdir(POST_DIR):
if not filename.endswith(".md"):
continue
path = os.path.join(
POST_DIR,
filename
)
try:
post = load_post(path)
self.posts.append(post)
except Exception as e:
print(
f"error: {path}: {e}"
)
raise
# Newest first.
self.posts.sort(
key=lambda post: post.date,
reverse=True
)
def previous(self, post):
index = self.posts.index(post)
if index + 1 >= len(self.posts):
return None
return self.posts[index + 1]
def next(self, post):
index = self.posts.index(post)
if index == 0:
return None
return self.posts[index - 1]
def tags(self):
"""
Return a dict mapping tag -> list of posts (newest
first), sorted alphabetically by tag.
"""
tags = defaultdict(list)
for post in self.posts:
for tag in post.tags:
tags[tag].append(post)
return dict(sorted(tags.items()))
# ------------------------------------------------------------
# Rendering
# ------------------------------------------------------------
def render_tags(post):
result = []
for tag in post.tags:
escaped = html.escape(tag)
result.append(
f'<a href="/blog/tags/{escaped}/">'
f'{escaped}</a>'
)
return " · ".join(result)
def render_post(blog, post):
template = load_template(
"post.html"
)
previous = blog.previous(post)
next_post = blog.next(post)
previous_html = ""
next_html = ""
if previous:
previous_html = (
f'<a href="{previous.url}">'
f'← {html.escape(previous.title)}</a>'
)
if next_post:
next_html = (
f'<a href="{next_post.url}">'
f'{html.escape(next_post.title)} →</a>'
)
values = {
"title": html.escape(post.title),
"date": post.date.strftime("%Y-%m-%d"),
"content": post.content,
"tags": render_tags(post),
"previous": previous_html,
"next": next_html,
}
return render(template, values)
def render_excerpt(post, limit=700):
"""
Generate a short HTML preview of a post.
The preview consists of complete paragraphs. If the
complete post does not fit within the limit, a Read more
link is appended.
"""
paragraphs = post.content.split("</p>")
output = []
length = 0
truncated = False
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
paragraph += "</p>"
if length + len(paragraph) > limit:
truncated = True
break
output.append(paragraph)
length += len(paragraph)
if not output:
return (
f'<p class="read-more">'
f'<a href="{post.url}">Read more →</a>'
f'</p>'
)
result = "\n".join(output)
if truncated:
result += (
f'\n<p class="read-more">'
f'<a href="{post.url}">Read more →</a>'
f'</p>'
)
return result
def render_post_list(posts):
entries = []
for post in posts:
tags = render_tags(post)
entries.append(
f"""
<article>
<h2>
<a href="{post.url}">
{html.escape(post.title)}
</a>
</h2>
<p class="metadata">
{post.date:%Y-%m-%d}
</p>
{render_excerpt(post)}
<p class="tags">
Tags: {tags}
</p>
</article>
"""
)
return "\n".join(entries)
def render_index(blog):
template = load_template(
"index.html"
)
values = {
"posts": render_post_list(blog.posts)
}
return render(template, values)
def render_tags_index(blog):
template = load_template(
"tags.html"
)
items = []
for tag, posts in blog.tags().items():
escaped = html.escape(tag)
items.append(
f'<li><a href="/blog/tags/{escaped}/">'
f'{escaped}</a> ({len(posts)})</li>'
)
values = {
"tags": "\n".join(items)
}
return render(template, values)
def render_tag_page(tag, posts):
template = load_template(
"tag.html"
)
values = {
"tag": html.escape(tag),
"posts": render_post_list(posts)
}
return render(template, values)
def render_archive(blog):
years = defaultdict(
lambda: defaultdict(list)
)
for post in blog.posts:
year = post.date.year
month = post.date.month
years[year][month].append(post)
output = []
for year in sorted(years, reverse=True):
output.append(
f'<section class="archive-year">'
)
output.append(
f'<h2>{year}</h2>'
)
for month in sorted(
years[year],
reverse=True
):
month_name = datetime(
year,
month,
1
).strftime("%B")
output.append(
f'<section class="archive-month">'
)
output.append(
f'<h3>{month_name}</h3>'
)
output.append(
'<ul class="archive-list">'
)
posts = sorted(
years[year][month],
key=lambda post: post.date,
reverse=True
)
for post in posts:
output.append(
'<li>'
f'<time datetime="{post.date_iso}">'
f'{post.date.day:02d}'
'</time> '
f'<a href="{post.url}">'
f'{html.escape(post.title)}'
'</a>'
'</li>'
)
output.append('</ul>')
output.append('</section>')
output.append('</section>')
return "\n".join(output)
# ------------------------------------------------------------
# Build
# ------------------------------------------------------------
def clean():
if os.path.isdir(OUTPUT_DIR):
shutil.rmtree(OUTPUT_DIR)
os.makedirs(
OUTPUT_DIR,
exist_ok=True
)
def build(blog):
print("Building blog...")
clean()
# Posts
for post in blog.posts:
output = os.path.join(
OUTPUT_DIR,
post.slug + ".html"
)
with open(output, "w", encoding="utf-8") as f:
f.write(
render_post(blog, post)
)
print(
f" post: {post.slug}"
)
# Index
index_path = os.path.join(
OUTPUT_DIR,
"index.html"
)
with open(index_path, "w", encoding="utf-8") as f:
f.write(render_index(blog))
print(" index")
# Static files
if os.path.isdir(STATIC_DIR):
for filename in os.listdir(STATIC_DIR):
source = os.path.join(
STATIC_DIR,
filename
)
destination = os.path.join(
"www",
filename
)
shutil.copy2(
source,
destination
)
build_archive(blog)
build_tags(blog)
print(
f"Built {len(blog.posts)} posts."
)
def build_archive(blog):
template = load_template(
"archive.html"
)
values = {
"archive": render_archive(blog)
}
output = os.path.join(
OUTPUT_DIR,
"archive",
"index.html"
)
os.makedirs(
os.path.dirname(output),
exist_ok=True
)
with open(
output,
"w",
encoding="utf-8"
) as f:
f.write(
render(template, values)
)
print(" archive")
def build_tags(blog):
tags = blog.tags()
# Tag index
index_output = os.path.join(
OUTPUT_DIR,
"tags",
"index.html"
)
os.makedirs(
os.path.dirname(index_output),
exist_ok=True
)
with open(
index_output,
"w",
encoding="utf-8"
) as f:
f.write(render_tags_index(blog))
# One page per tag
for tag, posts in tags.items():
output = os.path.join(
OUTPUT_DIR,
"tags",
tag,
"index.html"
)
os.makedirs(
os.path.dirname(output),
exist_ok=True
)
with open(
output,
"w",
encoding="utf-8"
) as f:
f.write(render_tag_page(tag, posts))
print(
f" tags ({len(tags)})"
)
# ------------------------------------------------------------
# CLI
# ------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Milán Major's tiny blog engine"
)
subparsers = parser.add_subparsers(
dest="command"
)
subparsers.add_parser(
"build",
help="build the website"
)
subparsers.add_parser(
"clean",
help="remove generated files"
)
args = parser.parse_args()
if args.command == "clean":
clean()
return
if args.command == "build":
blog = Blog()
blog.load()
print(
f"Loaded {len(blog.posts)} posts."
)
build(blog)
return
parser.print_help()
if __name__ == "__main__":
main()