Markdown Renderer

All simple webpages on this site are written in standard Markdown. Add this .htaccess to the Apache public root and any .md file will be rendered as HTML.

.htaccess

RewriteEngine On

# Folder request:
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_FILENAME}/index.md -f
RewriteRule ^ /markdown_renderer.php [L]

# A .md file request:
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule \.md$ /markdown_renderer.php [L]

markdown_renderer.php

<?php
declare(strict_types=1);

const ASSET_BASE = '/archive_viewer_assets';

function asset_url(string $name): string
{
    $url    = ASSET_BASE . '/' . $name;
    $fsPath = ($_SERVER['DOCUMENT_ROOT'] ?? '') . ASSET_BASE . '/' . $name;
    $mtime  = @filemtime($fsPath);
    if ($mtime !== false) {
        $url .= '?v=' . $mtime;
    }
    return $url;
}

$docroot = realpath($_SERVER['DOCUMENT_ROOT'] ?? __DIR__) ?: __DIR__;

$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uri = rawurldecode($uri);

if (PHP_SAPI === 'cli-server') {
    $fsPath = $docroot . $uri;
    if ($uri !== '/' && is_file($fsPath)
        && strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)) !== 'md') {
        return false;
    }
}

$target = resolve_markdown($docroot, $uri);

if ($target === null) {
    http_response_code(404);
    render_page('Not found', '<h1>404 — Not found</h1><p>No page here.</p>');
    exit;
}

$markdown = (string) file_get_contents($target);
$title    = extract_title($markdown, 'Untitled');
render_page($title, Markdown::render($markdown));

function resolve_markdown(string $docroot, string $uri): ?string
{
    if (strpos($uri, "\0") !== false) {
        return null;
    }
    $fsPath = $docroot . $uri;

    if (is_dir($fsPath)) {
        $candidate = rtrim($fsPath, '/') . '/index.md';
    } elseif (substr($uri, -3) === '.md') {
        $candidate = $fsPath;
    } else {
        return null;
    }

    $real = realpath($candidate);
    if ($real === false || !is_file($real)) {
        return null;
    }

    if (strpos($real, $docroot . DIRECTORY_SEPARATOR) !== 0) {
        return null;
    }
    if (strtolower(pathinfo($real, PATHINFO_EXTENSION)) !== 'md') {
        return null;
    }
    return $real;
}

function extract_title(string $markdown, string $fallback): string
{
    foreach (explode("\n", $markdown) as $line) {
        if (preg_match('/^\s*#\s+(.+?)\s*#*\s*$/', $line, $m)) {
            return trim($m[1]);
        }
    }
    return $fallback;
}

function render_page(string $title, string $bodyHtml): void
{
    $safeTitle = htmlspecialchars($title, ENT_QUOTES);

    $themeCss = htmlspecialchars(asset_url('highlight-theme.css'), ENT_QUOTES);
    $hljsJs   = htmlspecialchars(asset_url('highlight.min.js'), ENT_QUOTES);

    header('Content-Type: text/html; charset=utf-8');
    echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$safeTitle}</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="{$themeCss}">
</head>
<body>
<header class="header">
  <a href="/"><img class="rose" src="/images/yorkshire_rose.svg"
   alt="White Rose of Yorkshire" /></a>
  <span class="header__id">Yorkshire&nbsp;Systems</span>
</header>
<main class="content">
{$bodyHtml}
</main>
<script src="{$hljsJs}"></script>
<script>
  document.querySelectorAll('.content pre code').forEach(function (el) {
    hljs.highlightElement(el);
  });
</script>
</body>
</html>
HTML;
}

final class Markdown
{
    /** @var string[] */
    private array $codeStore = [];

    public static function render(string $text): string
    {
        return (new self())->parse($text);
    }

    private function parse(string $text): string
    {
        $text = str_replace(["\r\n", "\r"], "\n", $text);
        $text = $this->extractFencedCode($text);

        $lines = explode("\n", $text);
        $html  = $this->parseBlocks($lines);

        // Restore fenced code blocks.
        $html = preg_replace_callback('/\x02CODE(\d+)\x02/', function ($m) {
            return $this->codeStore[(int) $m[1]];
        }, $html);

        return $html;
    }

    /** Pull fenced ``` / ~~~ code blocks out so they aren't touched by other rules. */
    private function extractFencedCode(string $text): string
    {
        return preg_replace_callback(
            '/^(```|~~~)[ \t]*([\w+-]*)[ \t]*\n(.*?)^\1[ \t]*$/ms',
            function ($m) {
                $lang = $m[2] !== '' ? ' class="language-' . htmlspecialchars($m[2], ENT_QUOTES) . '"' : '';
                $code = htmlspecialchars($m[3], ENT_QUOTES);
                $code = rtrim($code, "\n");
                $key  = count($this->codeStore);
                $this->codeStore[$key] = "<pre><code{$lang}>{$code}</code></pre>";
                return "\x02CODE{$key}\x02\n";
            },
            $text
        );
    }

    /** @param string[] $lines */
    private function parseBlocks(array $lines): string
    {
        $out   = [];
        $count = count($lines);
        $i     = 0;

        while ($i < $count) {
            $line = $lines[$i];

            // Placeholder for fenced code.
            if (preg_match('/^\x02CODE\d+\x02$/', trim($line))) {
                $out[] = trim($line);
                $i++;
                continue;
            }

            // Blank line.
            if (trim($line) === '') {
                $i++;
                continue;
            }

            // Heading.
            if (preg_match('/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m)) {
                $level = strlen($m[1]);
                $out[] = "<h{$level}>" . $this->inline($m[2]) . "</h{$level}>";
                $i++;
                continue;
            }

            // Horizontal rule.
            if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) {
                $out[] = '<hr>';
                $i++;
                continue;
            }

            // Blockquote.
            if (preg_match('/^\s*>/', $line)) {
                $buf = [];
                while ($i < $count && preg_match('/^\s*>/', $lines[$i])) {
                    $buf[] = preg_replace('/^\s*>\s?/', '', $lines[$i]);
                    $i++;
                }
                $out[] = '<blockquote>' . $this->parseBlocks($buf) . '</blockquote>';
                continue;
            }

            // Table (header row + delimiter row).
            if (strpos($line, '|') !== false
                && isset($lines[$i + 1])
                && preg_match('/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/', $lines[$i + 1])) {
                [$tableHtml, $consumed] = $this->parseTable($lines, $i);
                $out[] = $tableHtml;
                $i += $consumed;
                continue;
            }

            // List (ordered or unordered, with nesting).
            if (preg_match('/^(\s*)([-*+]|\d+[.)])\s+/', $line)) {
                [$listHtml, $consumed] = $this->parseList($lines, $i);
                $out[] = $listHtml;
                $i += $consumed;
                continue;
            }

            // Paragraph: gather consecutive non-blank, non-block lines.
            $buf = [];
            while ($i < $count) {
                $l = $lines[$i];
                if (trim($l) === '') break;
                if (preg_match('/^(#{1,6})\s/', $l)) break;
                if (preg_match('/^\s*>/', $l)) break;
                if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $l)) break;
                if (preg_match('/^(\s*)([-*+]|\d+[.)])\s+/', $l)) break;
                if (preg_match('/^\x02CODE\d+\x02$/', trim($l))) break;
                $buf[] = $l;
                $i++;
            }
            $para = $this->inline(implode("\n", $buf));
            $para = nl2br($para, false);
            $out[] = "<p>{$para}</p>";
        }

        return implode("\n", $out);
    }

    /**
     * Parse a (possibly nested) list starting at $start.
     * @param string[] $lines
     * @return array{0:string,1:int} [html, lines consumed]
     */
    private function parseList(array $lines, int $start): array
    {
        $count = count($lines);
        $i     = $start;

        preg_match('/^(\s*)([-*+]|\d+[.)])\s+/', $lines[$i], $m);
        $baseIndent = strlen($m[1]);
        $ordered    = (bool) preg_match('/\d/', $m[2]);
        $tag        = $ordered ? 'ol' : 'ul';

        $items = [];
        while ($i < $count) {
            $line = $lines[$i];

            if (trim($line) === '') {
                // Allow a single blank line inside a list if the next line continues it.
                if (isset($lines[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+[.)])\s+/', $lines[$i + 1], $mm)
                    && strlen($mm[1]) >= $baseIndent) {
                    $i++;
                    continue;
                }
                break;
            }

            if (!preg_match('/^(\s*)([-*+]|\d+[.)])\s+(.*)$/', $line, $im)) {
                break; // not a list item line
            }
            $indent = strlen($im[1]);
            if ($indent < $baseIndent) {
                break; // belongs to an outer list
            }
            if ($indent > $baseIndent) {
                // Nested list — attach to the previous item.
                [$nested, $consumed] = $this->parseList($lines, $i);
                if ($items) {
                    $items[count($items) - 1] .= $nested;
                }
                $i += $consumed;
                continue;
            }

            // Same-level item. Collect its content + any indented continuation lines.
            $content = [$im[3]];
            $i++;
            while ($i < $count) {
                $next = $lines[$i];
                if (trim($next) === '') break;
                if (preg_match('/^(\s*)([-*+]|\d+[.)])\s+/', $next)) break;
                if (preg_match('/^\s+\S/', $next)) {
                    $content[] = trim($next);
                    $i++;
                    continue;
                }
                break;
            }

            $text = implode("\n", $content);
            // Task list checkbox.
            if (preg_match('/^\[([ xX])\]\s+(.*)$/s', $text, $tm)) {
                $checked = strtolower($tm[1]) === 'x' ? ' checked' : '';
                $body    = '<input type="checkbox" disabled' . $checked . '> ' . $this->inline($tm[2]);
            } else {
                $body = $this->inline($text);
            }
            $items[] = "<li>{$body}";
        }

        $html = "<{$tag}>\n";
        foreach ($items as $it) {
            $html .= $it . "</li>\n";
        }
        $html .= "</{$tag}>";

        return [$html, $i - $start];
    }

    /**
     * @param string[] $lines
     * @return array{0:string,1:int}
     */
    private function parseTable(array $lines, int $start): array
    {
        $count  = count($lines);
        $header = $this->splitRow($lines[$start]);
        $aligns = $this->parseAligns($lines[$start + 1]);
        $i      = $start + 2;
        $rows   = [];
        while ($i < $count && trim($lines[$i]) !== '' && strpos($lines[$i], '|') !== false) {
            $rows[] = $this->splitRow($lines[$i]);
            $i++;
        }

        $th = '';
        foreach ($header as $idx => $cell) {
            $a   = $aligns[$idx] ?? '';
            $sty = $a ? " style=\"text-align:{$a}\"" : '';
            $th .= "<th{$sty}>" . $this->inline($cell) . '</th>';
        }
        $body = '';
        foreach ($rows as $row) {
            $body .= '<tr>';
            foreach ($header as $idx => $_) {
                $cell = $row[$idx] ?? '';
                $a    = $aligns[$idx] ?? '';
                $sty  = $a ? " style=\"text-align:{$a}\"" : '';
                $body .= "<td{$sty}>" . $this->inline($cell) . '</td>';
            }
            $body .= '</tr>';
        }

        $html = "<table>\n<thead><tr>{$th}</tr></thead>\n<tbody>\n{$body}\n</tbody>\n</table>";
        return [$html, $i - $start];
    }

    /** @return string[] */
    private function splitRow(string $row): array
    {
        $row = trim($row);
        $row = preg_replace('/^\||\|$/', '', $row);
        // Split on unescaped pipes.
        $parts = preg_split('/(?<!\\\\)\|/', $row);
        return array_map(fn($c) => str_replace('\\|', '|', trim($c)), $parts);
    }

    /** @return string[] */
    private function parseAligns(string $delim): array
    {
        $cells  = $this->splitRow($delim);
        $aligns = [];
        foreach ($cells as $c) {
            $left  = substr($c, 0, 1) === ':';
            $right = substr($c, -1) === ':';
            if ($left && $right)      $aligns[] = 'center';
            elseif ($right)           $aligns[] = 'right';
            elseif ($left)            $aligns[] = 'left';
            else                      $aligns[] = '';
        }
        return $aligns;
    }

    /* ---- Inline formatting ------------------------------------------------ */

    private function inline(string $text): string
    {
        // Protect inline code spans first.
        $spans = [];
        $text  = preg_replace_callback('/(`+)(.+?)\1/s', function ($m) use (&$spans) {
            $key = count($spans);
            $spans[$key] = '<code>' . htmlspecialchars(trim($m[2]), ENT_QUOTES) . '</code>';
            return "\x01SPAN{$key}\x01";
        }, $text);

        // Escape the rest.
        $text = htmlspecialchars($text, ENT_QUOTES);

        // Images: ![alt](src "title")
        $text = preg_replace_callback(
            '/!\[(.*?)\]\(\s*(\S+?)(?:\s+&quot;(.*?)&quot;)?\s*\)/',
            function ($m) {
                $alt   = $m[1];
                $src   = $m[2];
                $title = isset($m[3]) && $m[3] !== '' ? " title=\"{$m[3]}\"" : '';
                return "<img src=\"{$src}\" alt=\"{$alt}\"{$title}>";
            },
            $text
        );

        // Links: [text](href "title")
        $text = preg_replace_callback(
            '/\[(.*?)\]\(\s*(\S+?)(?:\s+&quot;(.*?)&quot;)?\s*\)/',
            function ($m) {
                $label = $m[1];
                $href  = $m[2];
                $title = isset($m[3]) && $m[3] !== '' ? " title=\"{$m[3]}\"" : '';
                $ext   = preg_match('#^https?://#', html_entity_decode($href)) ? ' rel="noopener"' : '';
                return "<a href=\"{$href}\"{$title}{$ext}>{$label}</a>";
            },
            $text
        );

        // Autolinks: bare URLs.
        $text = preg_replace(
            '#(?<!["=>])\bhttps?://[^\s<]+#',
            '<a href="$0" rel="noopener">$0</a>',
            $text
        );

        // Bold + italic.
        $text = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $text);
        $text = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $text);
        $text = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $text);
        $text = preg_replace('/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/s', '<em>$1</em>', $text);
        $text = preg_replace('/(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])/s', '<em>$1</em>', $text);

        // Strikethrough.
        $text = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $text);

        // Restore code spans.
        $text = preg_replace_callback('/\x01SPAN(\d+)\x01/', fn($m) => $spans[(int) $m[1]], $text);

        return $text;
    }
}