evennia/devblog/_src/build_devblog.py
2022-12-03 19:25:55 +01:00

329 lines
9.1 KiB
Python

"""
A simple tool for building the blog from markdown files. Uses Jinja2 for templating and
mistletoe for markdown->html generation.
Each blog file should be named `YYYY-MM-DD-Name-of-Post.md` It can have a header
with meta info:
```
title: Title in free form
date: Date in free form (if different from current date)
copyrights: Image references/links in markdown format (all on one line)
---
Here starts the main text ...
```
"""
import glob
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from os import chdir, remove, symlink
from os.path import abspath, dirname
from os.path import join as pathjoin
from os.path import sep
import jinja2
import mistletoe
import rfeed
from dateutil import parser as dateparser
from mistletoe import HTMLRenderer
from pygments import highlight
from pygments.formatters.html import HtmlFormatter
from pygments.lexers import get_lexer_by_name as get_lexer
from pygments.lexers import guess_lexer
from pygments.styles import get_style_by_name as get_style
CURRDIR = dirname(abspath(__file__))
SOURCE_DIR = pathjoin(CURRDIR, "markdown")
TEMPLATE_DIR = pathjoin(CURRDIR, "templates")
IMG_DIR_NAME = "images"
IMG_DIR = pathjoin(CURRDIR, "images")
IMG_REL_LINK = "_src/images"
OUTDIR = dirname(CURRDIR)
OUTFILE_TEMPLATE = "{year}.html"
OUT_IMG_DIR = "images"
START_PAGE = "index.html"
BLOG_TEMPLATE = "blog.html"
POST_TEMPLATE = "post.html"
RSS_FEED_FILE = "feed.rss"
RSS_FEED = "feed"
CURRENT_YEAR = datetime.utcnow().year
@dataclass
class BlogPost:
"""
A single blog post.
"""
title: str
blurb: str
permalink: str
pagelink: str
anchor: str
date_pretty: str
date_short: str
date_sort: int
image_copyrights: str
html: str
@dataclass
class BlogPage:
"""
Represents one year/html page of blog posts.
"""
year: int
permalink: str
posts: list
calendar: dict
class PygmentsRenderer(HTMLRenderer):
"""
Custom syntax highlighter for misteltoe (based on
https://github.com/miyuchina/mistletoe/blob/master/contrib/pygments_renderer.py)
"""
formatter = HtmlFormatter()
formatter.noclasses = True
def __init__(self, *extras, style="default"):
super().__init__(*extras)
self.formatter.style = get_style(style)
def render_block_code(self, token):
code = token.children[0].content
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
return highlight(code, lexer, self.formatter)
def build_rss_feed(blogposts):
"""
Create a rss feed with all blog posts.
Args:
blogposts: A list of `BlogPost` entries.
"""
print("Rebuilding RSS feed ...")
feeditems = []
for blogpost in blogposts:
feeditems.append(
rfeed.Item(
title=blogpost.title,
link=blogpost.permalink,
description=blogpost.blurb,
author="Griatch",
guid=rfeed.Guid(blogpost.permalink),
pubDate=datetime.fromordinal(blogpost.date_sort),
)
)
return rfeed.Feed(
title="Evennia Devblog RSS Feed",
link="https://www.evennia.com/devblog/feed.rss",
description="""Evennia is a modern Python library and server for creating text-based
multi-player games and virtual worlds (also known as MUD, MUSH, MU,
MUX, MUCK, etc). While Evennia handles all the necessary things every
online game needs, like database and networking, you create the game of
your dreams by writing normal Python modules.""",
language="en-US",
lastBuildDate=datetime.utcnow(),
items=feeditems,
).rss()
def md2html():
"""
Generate all blog pages, with one page per year.
"""
jinja_templates = jinja2.FileSystemLoader(TEMPLATE_DIR)
jinja_env = jinja2.Environment(loader=jinja_templates)
blog_template = jinja_env.get_template(BLOG_TEMPLATE)
post_template = jinja_env.get_template(POST_TEMPLATE)
calendar = defaultdict(list)
for file_path in glob.glob(pathjoin(SOURCE_DIR, "*.md")):
# parse/check if file is on form YY-MM-DD-Blog-Name.md
filename = file_path.rsplit(sep, 1)[1] if sep in file_path else file_path
try:
year, month, day, title = filename.split("-", 3)
except ValueError:
print(f"Warning: Markdown file '{filename}' is not on a recognized format. Skipping.")
continue
title = title[:-3] # remove .md ending
title = " ".join(title.split("-"))
date = datetime(year=int(year), month=int(month), day=int(day))
image_copyrights = ""
with open(file_path) as fil:
lines = fil.readlines()
# check if there is a meta header in the first 6 lines
meta = None
try:
meta_end = [lin.strip() for lin in lines[:5]].index("---")
except ValueError:
pass
else:
meta = lines[:meta_end]
lines = lines[meta_end + 1 :]
for line in meta:
line = line.strip()
if line.startswith("title:"):
title = line.strip()[6:]
elif line.startswith("date:"):
date = dateparser.parse(line[5:])
elif line.startswith("copyrights:"):
image_copyrights = line[12:]
image_copyrights = mistletoe.markdown(image_copyrights)
# get first paragraph as blurb
markdown_blurb = "\n".join(
[line for line in lines if line and not line.startswith("!")][:3]
)
markdown_post = "\n".join(lines)
# convert markdown to html
blurb = mistletoe.markdown(markdown_blurb)
html = mistletoe.markdown(markdown_post, PygmentsRenderer)
# build the permalink
anchor = "{}".format(date.strftime("%Y-%m-%d-" + "-".join(title.strip().lower().split())))
pagelink = OUTFILE_TEMPLATE.format(year=date.year)
permalink = "{}#{}".format(pagelink, anchor)
blogpost = BlogPost(
title=title,
blurb=blurb,
permalink=permalink,
pagelink=pagelink,
anchor=anchor,
date_pretty=date.strftime("%B %e, %Y"),
date_short=date.strftime("%b %e"),
date_sort=date.toordinal(),
image_copyrights=image_copyrights,
html=html,
)
# populate template with jinja and readd to blogpost
context = {"blogpost": blogpost}
blogpost.html = post_template.render(context)
# store
calendar[date.year].append(blogpost)
# make sure to sort all entries by date
blogpostlist = []
blogpages = []
for year in sorted(calendar, reverse=True):
blogposts = list(sorted(calendar[year], key=lambda post: -post.date_sort))
blogpostlist.extend(blogposts)
blogpages.append(
BlogPage(
year=year,
permalink=OUTFILE_TEMPLATE.format(year=year),
posts=blogposts,
calendar=calendar,
)
)
# generate the rss feed
rss_feed = build_rss_feed(blogpostlist)
# build the html blog pages, one per year
latest_post = blogpages[0].posts[0]
latest_title = latest_post.title
latest_blurb = latest_post.blurb
html_pages = {}
for blogpage in blogpages:
print(f"Processing blogs from {blogpage.year} ...")
context = {
"latest_title": latest_title,
"latest_blurb": latest_blurb,
"pageyear": blogpage.year,
"blogpage": blogpage,
"blogpages": blogpages,
}
html_page = blog_template.render(context)
html_pages[blogpage.year] = html_page
return html_pages, rss_feed
def build_pages(blog_pages):
"""
Generate devblog pages.
"""
for html_file in glob.glob(pathjoin(OUTDIR, "*.html")):
remove(html_file)
try:
remove(pathjoin(OUTDIR, START_PAGE))
except FileNotFoundError:
pass
try:
remove(pathjoin(OUTDIR, IMG_DIR_NAME))
except FileNotFoundError:
pass
try:
remove(pathjoin(OUTDIR, RSS_FEED))
except FileNotFoundError:
pass
html_pages, rss_feed = md2html()
print("Writing HTML files and RSS feed ...")
# build html files
latest_year = -1
latest_page = None
for year, html_page in html_pages.items():
filename = pathjoin(OUTDIR, OUTFILE_TEMPLATE.format(year=year))
if year > latest_year:
latest_year = year
latest_page = filename.rsplit(sep, 1)[1] if sep in filename else filename
with open(filename, "w") as fil:
fil.write(html_page)
# build rss file
with open(pathjoin(OUTDIR, RSS_FEED_FILE), "w") as fil:
fil.write(rss_feed)
# link static resources and the start page
chdir(OUTDIR)
symlink
symlink(IMG_REL_LINK, IMG_DIR_NAME)
symlink(latest_page, START_PAGE)
symlink(RSS_FEED_FILE, RSS_FEED)
print(f"Output HTML + RSS written to {OUTDIR}{sep}. Latest year is {latest_year}.")
if __name__ == "__main__":
pages = md2html()
build_pages(pages)