# Markdown parser, template engine, and then some

I've got my own website! Woo! It's got HTML and everything! But after struggling for a while with HTML and keeping my navbar consistent across pages, I gave up and wrote a program which:

• Searches for every markdown file in the given root directory or below.
• Parses it into HTML with mistune, which thankfully only needs the one file to work
• Inserts it into a given template file which contains stuff like a navbar and a footer
• Looks for a similarly-named .js or .css file and links those if appropriate
• Looks for a page-title thing at the beginning and sets the title to that if it exists
• Puts it all together into a .html file
• Searches through the .html file for any URLs which point to the root of the site and modifies them to point to the real root if, like me, your content is hosted in a subdirectory
• Writes all that fun stuff to the hard drive for Apache to read

In short, it mashes everything together for a website that actually looks pretty good.

# Configuration options:
# =====================

# Where are the files to parse located? (generally ~/www)
# Don't include the trailing slash.

# Is your site hosted at some prefix? Set ROOT_URL to wherever
# that is and all the URLs which start from root (i.e. start
# with "/") will have it prepended. No trailing slash.

# Suppress logging if in fast mode to speed it up some
DEBUG_MODE = False

# ================ #
# Code starts here #
# Do not modify it #
# ================ #

# Imports not at the top because that way it's JUST configuration
# stuff up there.
from glob import glob
import mistune, os, re, sys, time

def log(a):
if DEBUG_MODE:
print(a)

def root_sub(m):
url = m.group(0)
if url.startswith(ROOT_URL):
print("    Found URL starting with site root! Change it!")
return url
if url.startswith("/"):
log("    Prepending site root to URL")
return ROOT_URL + url
else:
log("    URL is relative; not prepending site root")
return url

def parse(page, style_path, script_path):
# Page styles
style_tag = ""
if style_path:
log("  Adding style path to file")
style_tag = "<link href=\"{}\" rel=\"stylesheet\" />"\
.format(os.path.basename(style_path))
else:
log("  No style file found")

# Page scripts
script_tag = ""
if script_path:
log("  Adding script path to file")
script_tag = "<script src=\"{}\" />"\
.format(os.path.basename(script_path))
else:
log("  No script file found")

# Page title
match = re.search(r"^page-title: ?(.*?)\$", page, re.M)
if match:
log("  Extracting title")
title_tag = "<title>" + match.group(1) + "</title>"
page = page[:match.start()] + page[match.end():]
else:
log("  No title found; defaulting to 'Untitled Page'")
title_tag = "<title>Untitled Page</title>"

# Writing the data
log("  Parsing markdown")
parsed = md(page)
log("  Performing template replacement")
templated = (template  # This odd formatting makes it easy to reorder/add
.replace("[[version]]", version)
.replace("[[style-tag]]", style_tag)
.replace("[[script-tag]]", script_tag)
.replace("[[title-tag]]", title_tag)
.replace("[[markdown]]", parsed)
)
if (ROOT_URL and ROOT_URL != "/"):
templated = re.sub(r'href="/', r'href="{}/'.format(ROOT_URL),
templated)
log("  Done")
else:
log("  Root is just the normal root; no prepending needed.")
return templated

# Setup
version = time.strftime("%H:%M:%S %Y-%m-%d")
print("Publishing version {}".format(version))
md = mistune.Markdown()
files = list(glob(ROOT_PATH + "/**/*.md")) + list(glob(ROOT_PATH + "/*.md"))
template_path = "./_template.html"
log("Template file at {}".format(template_path))
log("Files to parse: {}".format(", ".join(files)))
with open(template_path) as template_file:

for i in range(0, len(files)):
# Per-file setup
path = files[i]
path_base = path[0:-3]  # This is safe because we know it's always .md
out_path = path_base + ".html"
if not DEBUG_MODE:
print("Parsing {}/{}: {}".format(i + 1, len(files), path))
log("Parsing {} -> {}...".format(path, out_path))
log("  Calculating paths")
style_path = path_base + ".css"
log("  Searching for stylesheet at {}".format(style_path))
script_path = path_base + ".js"
log("  Searching for scripts at {}".format(script_path))

with open(path, "r") as parsing:
log("  Done")

parsed = parse(raw,
style_path if os.path.isfile(style_path) else None,
script_path if os.path.isfile(script_path) else None)

log("  Writing output to file...")
log("    Opening file for output")
with open(out_path, "w") as out:
log("    Writing data")
out.write(parsed)
log("  Done")
log("Done")

print("Completed parsing for version {}.".format(version))


• Efficiency -- Both in terms of speed and memory. I'm especially concerned about the constant calls to log -- if it's not in debug mode, do these have any overhead, or does Python ignore them because it can tell that the function doesn't do anything?
• Readability -- I tried to make the code understandable, but in places it's confusing.
• Fewer long, long blocks of code -- like the parse function, which I can't figure out how to reasonably split into pieces.
• Safety -- Does my URL replacement only happen when I expect it to (i.e. in URLs, or stuff that looks reasonably like one)? What about my page-title searching?

I'm not at all concerned about

• Perfect compliance with PEP8 -- there are a bunch of guidelines that I simply don't care about, because they don't make sense to me. Feel free to point out violations, but I've run this through a validator and fixed the things I cared about, so anything left over is irrelevant to me.
• Cross-platform functioning -- this is a nice bonus, but frankly unnecessary, since I'll probably never use a non-Unix box.

Note: I'm strictly limited to Python 2.7.5 by the server I'm working on -- I can't install 3 or even a newer version of 2.

# One-lining log

Your log method can become this:

print(a) if DEBUG_MODE else None


This requires either python 3 or an import: from __future__ import print_function

# Unnecessary parens

if (ROOT_URL and ROOT_URL != "/")


You don't need the brackets/parens around that conditional, there are only two conditions.

# range

range(0, len(files)) can just be range(len(files)), because range starts at 0 by default. The syntax is essentially range(start=0, stop, step=1) - the only mandatory param is stop.

# Filename parsing

If you want to parse filenames by yourself rather than using a library, then while your current solution:

path_base = path[0:-3]


is technically safe, and is fine for your current script, but if you want to adapt it for another script, it'll break. A more general, equivalent one-liner is:

path_base = ".".join(path.split('.')[0:-1])


# String replacement

If you've got a lot of replacements, or you plan on adding more, try an extensible solution instead of all the replace calls:

replacements = {
'version': version,
'style-tag': style_tag,
'script_tag': script_tag,
'title-tag': title_tag,
'markdown': parsed
}
templated = template
for repl_name, repl_val in replacements.iteritems():
templated.replace('[[{}]]'.format(repl_name), repl_value)

• print(a) if DEBUG_MODE won't work. Are you thinking of Ruby? – 200_success Oct 6 '16 at 23:11
• @200_success Entirely possible. I've added the else clause, so it works now (verified locally). – ArtOfCode Oct 6 '16 at 23:17