How this blog-site was built


I've always wanted to be able to conveniently write blogs in Markdown and have them exported to HTML without actually having to write HTML-'code'.

First Implementation

Since I was too lazy to implement the Markdown-HTML-conversion by myself, I've decided to use pandoc for this task.

Consider the following directory structure for my personal website:

├── build.fish
├── dist
│   └── ...
└── src
    ├── blogs
    │   ├── how_built.md
    │   ├── index.html
    │   └── template.html
    ├── index.html
    ├── index.js
    ├── projects.html
    └── projects.js

The dist-folder will contain the final site, which is converted from the Markdown-files inside of src/blogs. build.fish is supposed to do this job:

#!/usr/bin/env fish

function get_html_file
    path change-extension 'html' (basename "$argv[1]")
end

function get_articles
    printf '<ul>'
    for file in src/blogs/*.md;
        set -l title (
            sed -n '2,/^---$/ {/^---$/d; p}' "$file" \
                | yq -acMr '.title' \
                | tr -d '"'
        )
        printf \
            '<li><a href="%s">%s</a></li>' \
            (get_html_file "$file") \
            "$title"
    end
    printf '</ul>'
end

mkdir -p dist
mkdir -p dist/blogs
cp src/*.html src/*.js dist/

printf '%s' (get_articles) \
    | pandoc --standalone --template src/blogs/index.html \
    > dist/blogs/index.html

for file in src/blogs/*.md;
    echo "$file"
    set tmpl_file 'src/blogs/template.html'
    pandoc \
        --standalone \
        --template "$tmpl_file" \
        "$file" \
        -o "dist/blogs/$(get_html_file "$file")"
end

There are some things to unpack right here. Each Markdown-file contains an YAML-metadata-block, which typically looks like the following:

---
title: Test title
---

# hello world
...

Inside of get_articles, I use sed to extract this YAML-metadata and yq to get the title-variable.

Both src/blogs/index.html and src/blogs/template.html act as pandoc-templates.

The get_articles-function returns a list of blogs and I use pandoc as a 'more robust' way of inserting this list into the final dist/blogs/index.html-file. Finally, I, once again, iterate over the Markdown-files and convert them to HTML-files while using src/blogs/template.html as a template.

It would surely be more efficient to iterate only once and move the conversion-code into get_articles. At the time of writing this, however, I didn't really care as much for performance. Additionally, iterating twice probably isn't the biggest performance-bottleneck right here, instead the pandoc-conversion is actually what takes the longest.

New approach

For performance reasons, I've decided to reimplement my build.fish script in C without using pandoc for document conversion. My new implementation uses md4c to convert the Markdown documents and nob.h as more of a convenience utility. Using nob.h sort of brings a "batteries-included"-feeling to C.

As the repository to this website is not publicly available on e.g. GitHub, I'll supply the latest version of my code right here. It may act as an usage example for a few things:

Using md4c instead of pandoc made me change the YAML-metadata-block of each Markdown-file to only contain the title as a string. It is easier to parse and I only need the title-variable as of right now, so why not just hard-code it?

#include <regex.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

#include "md4c-html.h"

#define NOB_IMPLEMENTATION
#include "nob.h"

#define STATIC_STRLEN(s) (int)(sizeof((s)) - 1)
#define STATIC_SV(s)                                                           \
    (Nob_String_View) { .data = (s), .count = STATIC_STRLEN((s)) }

#define DIR_SRCS "./src"
#define DIR_SRCS_BLOGS DIR_SRCS "/blogs"
#define DIR_DIST "./dist"
#define DIR_DIST_BLOGS DIR_DIST "/blogs"

typedef struct s_regex_replacement {
    Nob_String_View var_name;
    Nob_String_View replacement;
} regex_replacement;

static const Nob_String_View allowed_extensions_src[] = {STATIC_SV(".html"),
                                                         STATIC_SV(".js")};
static Nob_String_Builder html_blog_list = {0};
static Nob_String_Builder file_blog_template = {0};

static const char *variable_regex_str = "\\$\\(\\w\\+\\)\\$";
static regex_t variable_regex;
static size_t variable_regex_groups;

static bool regex_init(void) {
    if (regcomp(&variable_regex, variable_regex_str, REG_NEWLINE) != 0) {
        nob_log(NOB_ERROR, "Failed to compile variable-regex!");
        return false;
    }

    variable_regex_groups = variable_regex.re_nsub + 1;
    if (variable_regex_groups != 2)
        return false;

    return true;
}

static bool regex_replace_variables(Nob_String_Builder content,
                                    Nob_String_Builder *final_content,
                                    const regex_replacement *replacements,
                                    size_t replacements_len) {
    regmatch_t pmatch[variable_regex_groups];
    const char *template_cont = nob_temp_sv_to_cstr(nob_sb_to_sv(content));
    const char *initial_template_cont = template_cont;

    while (regexec(&variable_regex, template_cont, variable_regex_groups,
                   pmatch, 0) == 0) {
        // group 1 of the regex captures the name of each variable,
        // e.g. "body" for variable "$body$"
        regoff_t var_name_len = pmatch[1].rm_eo - pmatch[1].rm_so;
        Nob_String_View var_name =
            nob_sv_from_parts(template_cont + pmatch[1].rm_so, var_name_len);

        Nob_String_View replacement = {0};
        for (size_t i = 0; i < replacements_len; ++i) {
            if (nob_sv_eq(var_name, replacements[i].var_name)) {
                replacement = replacements[i].replacement;
            }
        }

        if (replacement.count == 0) {
            nob_log(NOB_ERROR, "Unknown variable: " SV_Fmt, SV_Arg(var_name));
            return false;
        }

        nob_sb_append_buf(final_content, template_cont, pmatch[0].rm_so);
        nob_sb_append_sv(final_content, replacement);
        template_cont += pmatch[0].rm_eo;
    }

    // we have to append the remaining content after the last
    // regex-match
    //
    // at this point, template_cont will point to the end of the last
    // regex-match
    //
    long int file_end_start = template_cont - initial_template_cont;
    nob_sb_append_buf(final_content, template_cont,
                      file_blog_template.count - file_end_start);

    return true;
}

static void process_output(const MD_CHAR *text, MD_SIZE size, void *userdata) {
    if (userdata) {
        nob_sb_append_buf((String_Builder *)userdata, text, size);
    }
}

static bool walk_src_dir(Nob_Walk_Entry entry) {
    if (entry.type != NOB_FILE_REGULAR)
        return true;

    nob_log(NOB_INFO, "Processing file: %s", entry.path);

    Nob_String_View path_sv = nob_sv_from_cstr(entry.path);
    Nob_String_View path_ext = nob_sv_from_cstr(nob_temp_file_ext(entry.path));
    Nob_String_View path_basename =
        nob_sv_from_cstr(nob_temp_file_name(entry.path));
    Nob_String_View path_dirname =
        nob_sv_from_cstr(nob_temp_dir_name(entry.path));
    switch (entry.level) {
    case 1: {
        Nob_String_View extension = {0};
        for (size_t i = 0; i < ARRAY_LEN(allowed_extensions_src); i++) {
            if (nob_sv_eq(path_ext, allowed_extensions_src[i])) {
                extension = allowed_extensions_src[i];
                break;
            }
        }

        if (extension.count == 0) {
            nob_log(NOB_WARNING, "Skipping file: Invalid extension (%s)",
                    entry.path);
            return false;
        }
        nob_log(NOB_INFO, "Found extension: " SV_Fmt, SV_Arg(extension));

        const char *new_path =
            nob_temp_sprintf(DIR_DIST "/" SV_Fmt, SV_Arg(path_basename));
        if (new_path == NULL) {
            nob_log(
                NOB_ERROR,
                "Printing to temp string failed, probably out of memory :/");
            return false;
        }

        nob_log(NOB_INFO, "Copying to: %s", new_path);
        if (!nob_copy_file(entry.path, new_path)) {
            nob_log(NOB_ERROR, "Failed to copy file, aborting!");
            return false;
        }
    } break;
    case 2: {
        if (!nob_sv_eq(path_dirname, STATIC_SV(DIR_SRCS_BLOGS))) {
            nob_log(NOB_WARNING, "Skipping directory: " SV_Fmt,
                    SV_Arg(path_dirname));
            return true;
        }

        if (nob_sv_eq(path_ext, STATIC_SV(".md"))) {
            Nob_String_Builder file_md = {0};
            if (!nob_read_entire_file(entry.path, &file_md))
                return false;

            // start: extract title from file header
            Nob_String_View file_md_view = nob_sb_to_sv(file_md);
            nob_sv_chop_by_delim(&file_md_view, '\n');

            size_t title_len;
            for (title_len = 0; title_len < file_md_view.count &&
                                file_md_view.data[title_len] != '\n';
                 ++title_len)
                ;

            Nob_String_View title =
                nob_sv_from_parts(file_md_view.data, title_len);
            nob_sv_trim(title);

            nob_sv_chop_prefix(&path_sv, STATIC_SV(DIR_SRCS));
            nob_sv_chop_suffix(&path_sv, path_ext);

            const char *tmp = nob_temp_sprintf(SV_Fmt ".html", SV_Arg(path_sv));
            const char *link_dest = nob_temp_file_name(tmp);

            nob_sb_appendf(&html_blog_list,
                           "<li><a href=\"%s\">" SV_Fmt "</a></li>", link_dest,
                           SV_Arg(title));
            nob_log(NOB_INFO, "Title: " SV_Fmt, SV_Arg(title));

            nob_sv_chop_by_delim(&file_md_view, '\n');
            nob_sv_chop_by_delim(&file_md_view, '\n');
            // end: extract title from file header

            Nob_String_Builder html_content = {0};
            if (md_html(file_md_view.data, (MD_SIZE)file_md_view.count,
                        process_output, &html_content, MD_DIALECT_GITHUB,
                        0) != 0) {
                nob_log(NOB_ERROR, "Failed to convert Markdown to HTML!");
                return false;
            }

            const regex_replacement replacements[] = {
                (regex_replacement){
                    .var_name = STATIC_SV("title"),
                    .replacement = title,
                },
                (regex_replacement){
                    .var_name = STATIC_SV("body"),
                    .replacement = nob_sb_to_sv(html_content),
                }};
            Nob_String_Builder file_final = {0};
            if (!regex_replace_variables(file_blog_template, &file_final,
                                         replacements,
                                         NOB_ARRAY_LEN(replacements))) {
                return false;
            }

            const char *new_path =
                nob_temp_sprintf(DIR_DIST "/" SV_Fmt ".html", SV_Arg(path_sv));
            if (!nob_write_entire_file(new_path, file_final.items,
                                       file_final.count))
                return false;
        }
    } break;
    }
    return true;
}

int main(void) {
    // NOTE: specifically not use GO_REBUILD_YOURSELF because it won't work with
    //	     nob.h being in 'includes/nob.h'

    if (!nob_mkdir_if_not_exists(DIR_DIST))
        return 1;
    if (!nob_mkdir_if_not_exists(DIR_DIST_BLOGS))
        return 1;

    if (!nob_read_entire_file(DIR_SRCS_BLOGS "/template.html",
                              &file_blog_template))
        return 1;

    regex_init();

    nob_sb_append_cstr(&html_blog_list, "<ul>");

    if (!nob_walk_dir(DIR_SRCS, walk_src_dir))
        return 1;

    nob_sb_append_cstr(&html_blog_list, "</ul>");

    Nob_String_View path_sv = STATIC_SV(DIR_SRCS_BLOGS "/index.html");
    Nob_String_Builder file_blogs_index = {0};
    if (!nob_read_entire_file(path_sv.data, &file_blogs_index))
        return false;

    const regex_replacement replacements[] = {(regex_replacement){
        .var_name = STATIC_SV("body"),
        .replacement = nob_sb_to_sv(html_blog_list),
    }};
    Nob_String_Builder file_final = {0};
    if (!regex_replace_variables(file_blogs_index, &file_final, replacements,
                                 NOB_ARRAY_LEN(replacements))) {
        return false;
    }

    const char *new_path = DIR_DIST_BLOGS "/index.html";
    if (!nob_write_entire_file(new_path, file_final.items, file_final.count))
        return false;
    return 0;
}

Usability-features

In order to improve user-experience, I've implemented the following features for blogs:

They work for every blog post, because I've implemented them inside of the template.html file.

Take a look at the code:

async function writeClipboardText(text) {
    try {
        await navigator.clipboard.writeText(text)
    } catch (error) {
        console.error(error.message)
    }
}

function createEntrySelector(entries, columns, columnSelector) {
    const newSelector = document.createElement("select")
    newSelector.classList.add("__entrySelector__")

    const selectorOption = document.createElement("option")
    selectorOption.innerText = "none"
    newSelector.appendChild(selectorOption)

    for (let i = columnSelector.selectedIndex; i < entries.length; i += columns.length) {
        const entry = entries[i]

        let canInsert = true
        for (const child of newSelector.children) {
            if (child.innerText === entry.innerText) {
                canInsert = false
            }
        }

        if (canInsert) {
            const selectorOption = document.createElement("option")
            selectorOption.innerText = entry.innerText
            newSelector.appendChild(selectorOption)
        }
    }

    newSelector.onchange = function () {
        for (const entry of entries) {
            entry.parentNode.classList.remove("hidden")
        }

        if (newSelector.selectedIndex === 0) {
            return
        }

        for (let i = columnSelector.selectedIndex; i < entries.length; i += columns.length) {
            const entry = entries[i]
            if (entry.innerText === newSelector.value) {
                continue
            }

            entry.parentNode.classList.add("hidden")
        }
    }

    return newSelector
}

const allCont = document.getElementById("cont")

const tables = allCont.querySelectorAll("table")
for (const table of tables) {
    const columns = table.querySelectorAll("thead tr th")
    const entries = table.querySelectorAll("tbody tr td")

    const filterDiv = document.createElement("div")
    filterDiv.style.paddingLeft = "0.5em"
    filterDiv.style.display = "flex"
    filterDiv.style.flexDirection = "row"
    filterDiv.style.gap = "1em"

    const filterText = document.createElement("p")
    filterText.style.padding = "0"
    filterText.style.margin = "0"
    filterText.innerText = "filter"
    filterDiv.appendChild(filterText)

    const columnSelector = document.createElement("select")
    for (const column of columns) {
        const selectorOption = document.createElement("option")
        selectorOption.innerText = column.innerText
        columnSelector.appendChild(selectorOption)
    }

    columnSelector.onchange = function () {
        for (const entry of entries) {
            entry.parentNode.classList.remove("hidden")
        }

        const oldSelector = filterDiv.querySelector(".__entrySelector__")
        const newSelector = createEntrySelector(entries, columns, columnSelector)

        oldSelector.replaceWith(newSelector)
    }

    filterDiv.appendChild(columnSelector)

    const filterByText = document.createElement("p")
    filterByText.style.padding = "0"
    filterByText.style.margin = "0"
    filterByText.innerText = "by"
    filterDiv.appendChild(filterByText)

    const entrySelector = createEntrySelector(entries, columns, columnSelector)
    filterDiv.appendChild(entrySelector)

    const filterHideText = document.createElement("p")
    filterHideText.innerText = "(hide)"
    filterHideText.style.cursor = "pointer"
    filterHideText.style.padding = "0"
    filterHideText.style.margin = "0"
    filterHideText.onclick = function () {
        for (const child of filterDiv.children) {
            if (child === filterHideText) continue
            if (child.classList.contains("hidden")) {
                child.classList.remove("hidden")
                filterHideText.innerText = "(hide)"
            } else {
                child.classList.add("hidden")
                filterHideText.innerText = "(show filter)"
            }
        }
    }
    filterDiv.appendChild(filterHideText)

    allCont.insertBefore(filterDiv, table)
}

const listings = allCont.querySelectorAll("pre")
for (const lst of listings) {
    const nodes = lst.childNodes
    if (nodes.length <= 0 || nodes[0].tagName !== "CODE") continue

    const btn = document.createElement("button")
    btn.innerHTML = 'Copy'
    btn.onclick = function () {
        writeClipboardText(nodes[0].innerText)
        alert("Text copied!")
    }

    lst.appendChild(btn)
}


const headers = allCont.querySelectorAll("h1, h2, h3, h4, h5, h6")
if (headers.length > 0) {
    const tocDiv = document.createElement("div")
    tocDiv.style.marginLeft = "2em"

    const tocText = document.createElement("h2")
    tocText.id = "toc"
    tocText.innerText = "Contents"
    tocDiv.appendChild(tocText)

    const tocList = document.createElement("ul")
    for (const header of headers) {
        header.id = header.innerText.toLowerCase().replaceAll(" ", "_")

        const tocListItem = document.createElement("li")
        const tocListItemInner = document.createElement("a")
        tocListItemInner.href = `#${header.id}`
        tocListItemInner.innerText = header.innerText

        tocListItem.appendChild(tocListItemInner)
        tocList.appendChild(tocListItem)

        const tocBackRef = document.createElement("a")
        tocBackRef.href = '#toc'
        tocBackRef.innerText = "(back to contents)"

        header.after(tocBackRef)
    }
    tocDiv.appendChild(tocList)

    const sepLine = document.createElement("hr")

    document.body.insertBefore(tocDiv, allCont)
    document.body.insertBefore(sepLine, allCont)
}