CONFIG

Custom rst processor for my Hugo site

#org-config #neovim
Published — Updated

I blogged about this. I should link to it maybe.

"""Give my reStructuredText posts in Hugo a little boost."""

import html
import locale
from typing import Any, Dict, List, Tuple

import frontmatter
import pynvim
from docutils import nodes
from docutils.core import publish_parts
from docutils.nodes import reference
from docutils.parsers.rst import Directive, directives, roles
from docutils.parsers.rst.directives.body import CodeBlock
from docutils.parsers.rst.states import Inliner
from PIL import Image

locale.setlocale(locale.LC_ALL, "")

NamedOptions = Dict[str, Any]
References = List[reference]
Strings = List[str]
ContentAndErrors = Tuple[References, References]


class CodeSample(CodeBlock):
    """A captioned code block."""

    required_arguments = 0
    optional_arguments = 1
    final_argument_whitespace = True
    option_spec = {
        "caption": str,
        "class": directives.class_option,
        "name": directives.unchanged,
        "number-lines": directives.unchanged,  # integer or None
    }

    def run(self):
        section = nodes.container()

        if "caption" in self.options:
            caption = self.options.pop("caption")
            section += nodes.caption(caption, caption)
        elif "name" in self.options:
            name = self.options["name"]
            caption_node = nodes.literal(name, name)
            caption_node.set_class("caption")
            section += caption_node

        code_block = super().run()
        section += code_block

        return [section]


directives.register_directive("code-sample", CodeSample)


class ImageLink(Directive):
    """Renders a thumbnail, summary, and download link for an image file."""

    required_arguments = 1  # the path to the file
    optional_arguments = 0
    final_argument_whitespace = False
    option_spec = {
        "class": directives.class_option,
        "name": directives.unchanged,
        "thumbnail": str,
    }

    def run(self):
        image_name = self.arguments[0]
        cwd = os.getcwd()
        image_path = os.path.join(cwd, image_name)
        image = Image.open(image_path)
        image_link_preview = self.__image_link_preview(image)
        image_link_details = self.__image_link_details(image)
        image_link = nodes.container()
        image_link.set_class("image-link")
        image_link += image_link_preview
        image_link += image_link_details

        return [image_link]

    def __detail_list(self, image):
        image_link = self.__image_link()
        image_link_item = nodes.list_item()
        image_link_item += image_link

        image_format = image.format
        image_format_inline = nodes.inline()
        image_format_text = nodes.Text(image.format)
        image_format_inline += image_format_text
        image_format_item = nodes.list_item()
        image_format_item += image_format_inline

        width, height = image.size
        image_dimensions_inline = nodes.inline()
        image_dimensions_text = nodes.Text(f"{width} pixels wide, {height} pixels high")
        image_dimensions_inline += image_dimensions_text
        image_dimensions_item = nodes.list_item()
        image_dimensions_item += image_dimensions_inline

        image_license_inline = nodes.inline()
        license_uri = "https://creativecommons.org/licenses/by/4.0/"
        license_name = "Attribution 4.0 International (CC BY 4.0)"
        license_reference = nodes.reference(
            internal=False, refuri=license_uri, text=license_name
        )
        license_prefix = nodes.Text("Licensed under ")
        image_license_inline += license_prefix
        image_license_inline += license_reference
        image_license_item = nodes.list_item()
        image_license_item += image_license_inline

        detail_list = nodes.bullet_list()
        detail_list += image_link_item
        detail_list += image_format_item
        detail_list += image_dimensions_item
        detail_list += image_license_item

        return detail_list

    def __image_link_details(self, image):
        detail_list = self.__detail_list(image)
        image_link_details = nodes.container()
        image_link_details.set_class("image-link-details")
        image_link_details += detail_list

        return image_link_details

    def __image_link(self):
        image_name = self.arguments[0]
        href = image_name
        link_text = nodes.literal()
        link_text += nodes.Text(image_name)
        link = nodes.reference(internal=False, refuri=href)
        link += link_text
        inline = nodes.inline()
        inline += link

        return inline

    def __image_link_preview(self, image):
        image_name = self.arguments[0]
        base, ext = os.path.splitext(image_name)
        thumbnail_name = f"{base}-96x96{ext}"
        image.thumbnail((96, 96))
        image.save(thumbnail_name)
        image_node = nodes.image(uri=thumbnail_name)
        image_link = nodes.reference(internal=False, refuri=image_name)
        image_link += image_node
        preview_node = nodes.container()
        preview_node.set_class("image-link-preview")
        preview_node += image_link

        return preview_node


directives.register_directive("image-link", ImageLink)


def role_raw_kbd(
    name: str,
    rawtext: str,
    text: str,
    lineno: int,
    inliner: Inliner,
    options: NamedOptions = {},
    content: Strings = [],
) -> ContentAndErrors:
    """Return literal text marked as keyboard input."""

    escaped_text = html.escape(text)
    kbd_html = f"<kbd>{escaped_text}</kbd>"
    options["format"] = "html"
    kbd_node = nodes.raw(rawtext, kbd_html, **options)

    return [kbd_node], []


roles.register_canonical_role("raw-kbd", role_raw_kbd)


def role_kbd(
    name: str,
    rawtext: str,
    text: str,
    lineno: int,
    inliner: Inliner,
    options: NamedOptions = {},
    content: Strings = [],
) -> ContentAndErrors:
    """Return literal text marked as keyboard input."""

    kbd_node = nodes.literal(rawtext, text, **options)
    kbd_node.set_class("keyboard")

    return [kbd_node], []


roles.register_canonical_role("kbd", role_kbd)


def role_term(
    name: str,
    rawtext: str,
    text: str,
    lineno: int,
    inliner: Inliner,
    options: NamedOptions = {},
    content: Strings = [],
) -> ContentAndErrors:
    """Return text marked as domain terminology."""

    term_node = nodes.strong(rawtext, text, **options)
    term_node.set_class("term")

    return [term_node], []


roles.register_canonical_role("term", role_term)


def role_reference_tag(
    name: str,
    rawtext: str,
    text: str,
    lineno: int,
    inliner: Inliner,
    options: NamedOptions = {},
    content: Strings = [],
) -> ContentAndErrors:
    """Return a reference to a site tag."""

    tag_text = f"#{text}"
    tag_ref = f"/tags/{text}"
    tag_node = nodes.reference(rawtext, tag_text, refuri=tag_ref, **options)
    tag_node.set_class("p-category")

    return [tag_node], []


roles.register_canonical_role("tag", role_reference_tag)


def determine_target(source: str) -> str:
    # Using an odd suffix so Hugo doesn't try to build the rst itself
    if not source.endswith(".rst.txt"):
        raise ValueError(f"Look at {source} more closely before transforming it.")

    return source.replace(".rst.txt", ".html")


@pynvim.plugin
class RSTBuildHugo:
    def __init__(self, nvim):
        self.nvim = nvim

    @pynvim.autocmd("BufWritePost", pattern="*.rst.txt", eval='expand("<afile>")')
    def convert_file(self, source_filename: str):
        target_path = determine_target(source_filename)
        post = frontmatter.load(source_filename)
        parts = publish_parts(source=post.content, writer_name="html")
        post.content = parts["body"]
        post.metadata["format"] = "rst"

        with open(target_path, "w") as out:
            out.write(frontmatter.dumps(post))

        self.nvim.out_write(f"Wrote {target_path}\n")