Static site generator

This website is generated from Markdown source files using the below script. My requirements are minimal (no blog archive pages or anything) so writing my own short script was a better choice than using an existing, overcomplicated static site generator.

Contents

Dependencies

build.py

Generates a .html file of the same name next to every .md file in the given directories.

#!/usr/bin/env python3

import glob, os, os.path, re, sys
from os.path import getmtime, isfile

from mako.template import Template
import markdown
import markdown.treeprocessors
import markdown.extensions.toc

class TitleExtension(markdown.Extension):
    """Sets md.title to the first heading in the input file"""
    class Treeprocessor(markdown.treeprocessors.Treeprocessor):
        def __init__(self, md):
            super().__init__(md)
            self.header_rgx = re.compile(r'[Hh][123456]')

        def run(self, doc):
            for el in doc.iter():
                if isinstance(el.tag, str) and self.header_rgx.match(el.tag):
                    self.md.title = markdown.extensions.toc.get_name(el)
                    return

    def extendMarkdown(self, md):
        self.md = md
        md.registerExtension(self)
        proc = self.Treeprocessor(md)
        md.treeprocessors.register(proc, 'title', 5)

    def reset(self):
        self.md.title = None

md = markdown.Markdown(
        extensions=['abbr', 'smarty', 'sane_lists', 'tables',
            'toc', TitleExtension()],
        extension_configs={
            'toc': {
                'marker': '{TOC}',
                'title': 'Contents',
                'toc_depth': '2-6'
            }
        })

def render(srcname, dstname, template):
    md.reset()
    with open(srcname, 'r') as src, open(dstname, 'w') as dst:
        content = md.convert(src.read())
        dst.write(template.render(
            title=md.title or 'Untitled',
            content=content,
            has_table='<table' in content,
            has_form='<form' in content,
            has_toc='<div class="toc' in content
        ))

def find_updated(dirs, template_mtime, verbose):
    updated = []
    for d in dirs:
        pattern = os.path.join(glob.escape(d), '**/*.md')
        for srcname in glob.glob(pattern, recursive=True):
            dstname = srcname[:-3]+'.html'
            if not isfile(dstname) \
                    or getmtime(srcname) > getmtime(dstname) \
                    or template_mtime > getmtime(dstname):
                updated.append((srcname, dstname))
            else:
                verbose and print('Up to date:', dstname)
    return updated

def adjacent_to_script(f):
    base = os.path.dirname(os.path.realpath(__file__))
    return os.path.join(base, f)

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Static site generator')
    parser.add_argument('dirs', metavar='dir', nargs='*',
            help='directories to process (default: public/ draft/)',
            default=[adjacent_to_script(d) for d in ('public', 'draft')])
    parser.add_argument('--template',
            default=adjacent_to_script('template.html'),
            help='Mako template to use (default: template.html)')
    parser.add_argument('-f', '--force', action='store_true',
            help='overwrite files without prompting')
    parser.add_argument('-v', '--verbose', action='store_true',
            help='print status of every processed file')
    args = parser.parse_args()
    updated = find_updated(args.dirs, getmtime(args.template), args.verbose)
    if updated:
        overwrite = [dst for src, dst in updated if isfile(dst)]
        if overwrite and not args.force:
            print('These files will be overwritten:')
            for dst in overwrite:
                print('  '+dst)
            response = input('Continue? [no] ')
            if len(response) < 1 or response[0].lower() != 'y':
                sys.exit(1)
        template = Template(filename=args.template)
        for src, dst in updated:
            if args.verbose:
                print('{}: {}'.format(
                    'Overwriting' if isfile(dst) else 'Creating',
                    dst))
            render(src, dst, template)

template.html

Styles are omitted here to show the bare minimum template. In the actual template, I use has_table, has_form, and has_toc in the <style> to keep the page size down a bit. I know, I could just use a linked stylesheet, but I prefer to instead keep my pages at only 1 request each. (Especially since my website is infrequently visited, so it won’t benefit much from caching.)

<!doctype html>
<html>
  <!-- Auto-generated file, do not edit -->
  <head>
    <meta charset="utf-8">
    <title>${title}</title>
    <meta name="viewport" content="initial-scale=1">
  </head>
  <body>
    ${content}
  </body>
</html>