Milán Major

devsite-builder

1 branch
Code

siteutils.py

"""
Shared helpers used by both the blog and git static site
generators.
"""

import os
import re
import html

TEMPLATE_DIR = "templates"

# The site header (name, nav, and Cross of Jerusalem mark) is
# identical on every page, so it lives here once instead of
# being duplicated across every template.
HEADER_HTML = """<header>
<a href="/" class="cross-link" aria-label="Home">
<svg class="cross" viewBox="0 0 100 100" aria-hidden="true">
<rect x="43" y="6" width="14" height="88"/>
<rect x="6" y="43" width="88" height="14"/>
<rect x="23" y="18" width="4" height="14"/>
<rect x="18" y="23" width="14" height="4"/>
<rect x="73" y="18" width="4" height="14"/>
<rect x="68" y="23" width="14" height="4"/>
<rect x="23" y="68" width="4" height="14"/>
<rect x="18" y="73" width="14" height="4"/>
<rect x="73" y="68" width="4" height="14"/>
<rect x="68" y="73" width="14" height="4"/>
</svg>
</a>
<div class="brand-text">
<h1><a href="/">Milán Major</a></h1>
<nav>
<a href="/">home</a>
<a href="/blog/">blog</a>
<a href="/git/">git</a>
<a href="/about.html">about</a>
</nav>
</div>
</header>"""


# ------------------------------------------------------------
# Markdown
#
# A small, dependency-free Markdown renderer shared by the blog
# (post bodies) and the git browser (README files). It covers
# the subset of Markdown commonly used in both: headings,
# paragraphs, code fences, lists, blockquotes, rules, links,
# images and inline emphasis/code. It doesn't handle nested
# lists or tables - kept deliberately simple.
# ------------------------------------------------------------

def inline_markdown(text):
    text = html.escape(text)

    text = re.sub(
        r"!\[([^\]]*)\]\(([^)]+)\)",
        r'<img src="\2" alt="\1">',
        text
    )

    text = re.sub(
        r"\[([^\]]+)\]\(([^)]+)\)",
        r'<a href="\2">\1</a>',
        text
    )

    text = re.sub(
        r"\*\*(.+?)\*\*",
        r"<strong>\1</strong>",
        text
    )

    text = re.sub(
        r"\*(.+?)\*",
        r"<em>\1</em>",
        text
    )

    text = re.sub(
        r"`(.+?)`",
        r"<code>\1</code>",
        text
    )

    return text


def markdown(text, heading_offset=0):
    """
    `heading_offset` shifts every heading level down (e.g. 1 turns
    a "# Title" into <h2>), useful when embedding a document (such
    as a README) below a page that already has its own <h1>.
    """

    lines = text.splitlines()
    output = []

    paragraph = []
    code_block = None
    code_lang = None
    list_kind = None
    list_items = None
    quote_lines = None

    def flush_paragraph():
        if not paragraph:
            return

        value = " ".join(paragraph)
        output.append(f"<p>{inline_markdown(value)}</p>")
        paragraph.clear()

    def flush_list():
        nonlocal list_kind, list_items

        if list_kind is None:
            return

        items_html = "\n".join(
            f"<li>{inline_markdown(item)}</li>" for item in list_items
        )
        output.append(f"<{list_kind}>\n{items_html}\n</{list_kind}>")

        list_kind = None
        list_items = None

    def flush_quote():
        nonlocal quote_lines

        if quote_lines is None:
            return

        value = " ".join(quote_lines)
        output.append(f"<blockquote><p>{inline_markdown(value)}</p></blockquote>")

        quote_lines = None

    for line in lines:
        stripped = line.strip()

        if code_block is not None:

            if stripped.startswith("```"):
                code = "\n".join(code_block)
                lang_attr = (
                    f' class="language-{code_lang}"' if code_lang else ""
                )
                output.append(
                    f"<pre><code{lang_attr}>{html.escape(code)}</code></pre>"
                )
                code_block = None
                code_lang = None
            else:
                code_block.append(line)

            continue

        if stripped.startswith("```"):

            # A line that opens *and* closes a fence on its own
            # (e.g. "``` some command ```") is a single-line code
            # span, not the start of a multi-line block - treat
            # it as such instead of swallowing every line up to
            # the next stray "```" elsewhere in the document.
            single_line = re.match(r"^`{3,}\s*(.+?)\s*`{3,}$", stripped)

            if single_line and "```" not in single_line.group(1):
                flush_paragraph()
                flush_list()
                flush_quote()
                output.append(
                    f"<pre><code>{html.escape(single_line.group(1))}"
                    "</code></pre>"
                )
                continue

            flush_paragraph()
            flush_list()
            flush_quote()
            code_block = []
            code_lang = stripped[3:].strip() or None
            continue

        if not stripped:
            flush_paragraph()
            flush_list()
            flush_quote()
            continue

        if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
            flush_paragraph()
            flush_list()
            flush_quote()
            output.append("<hr>")
            continue

        heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)

        if heading:
            flush_paragraph()
            flush_list()
            flush_quote()
            level = min(6, len(heading.group(1)) + heading_offset)
            output.append(
                f"<h{level}>{inline_markdown(heading.group(2))}</h{level}>"
            )
            continue

        if stripped.startswith(">"):
            flush_paragraph()
            flush_list()

            if quote_lines is None:
                quote_lines = []

            quote_lines.append(stripped[1:].strip())
            continue

        flush_quote()

        ordered = re.match(r"^\d+\.\s+(.*)$", stripped)
        unordered = re.match(r"^[-*+]\s+(.*)$", stripped)

        if ordered or unordered:
            flush_paragraph()
            kind = "ol" if ordered else "ul"
            item_text = (ordered or unordered).group(1)

            if list_kind != kind:
                flush_list()
                list_kind = kind
                list_items = []

            list_items.append(item_text)
            continue

        flush_list()
        paragraph.append(line)

    flush_paragraph()
    flush_list()
    flush_quote()

    if code_block is not None:
        code = "\n".join(code_block)
        output.append(f"<pre><code>{html.escape(code)}</code></pre>")

    return "\n".join(output)


def load_template(name):

    path = os.path.join(
        TEMPLATE_DIR,
        name
    )

    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def render(template, values):

    values = {"header": HEADER_HTML, **values}

    for key, value in values.items():

        template = template.replace(
            "{{ " + key + " }}",
            str(value)
        )

    return template