Milán Major

devsite-builder

1 branch
Code

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