git.tonybtw.com

https://git.tonybtw.com/git.tonybtw.com.git git://git.tonybtw.com/git.tonybtw.com.git

Initial commit.

Commit
dba9b307f94ff31a23eb6f353127d489f98b74f6
Author
tonybanters <tonybanters@gmail.com>
Date
2026-02-06 04:33:54

Diff

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..17881e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+test-repos/
+.direnv/
+notes/
diff --git a/app/lib/Parsedown.php b/app/lib/Parsedown.php
new file mode 100644
index 0000000..38edfe9
--- /dev/null
+++ b/app/lib/Parsedown.php
@@ -0,0 +1,1994 @@
+<?php
+
+#
+#
+# Parsedown
+# http://parsedown.org
+#
+# (c) Emanuil Rusev
+# http://erusev.com
+#
+# For the full license information, view the LICENSE file that was distributed
+# with this source code.
+#
+#
+
+class Parsedown
+{
+    # ~
+
+    const version = '1.8.0';
+
+    # ~
+
+    function text($text)
+    {
+        $Elements = $this->textElements($text);
+
+        # convert to markup
+        $markup = $this->elements($Elements);
+
+        # trim line breaks
+        $markup = trim($markup, "\n");
+
+        return $markup;
+    }
+
+    protected function textElements($text)
+    {
+        # make sure no definitions are set
+        $this->DefinitionData = array();
+
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        # remove surrounding line breaks
+        $text = trim($text, "\n");
+
+        # split text into lines
+        $lines = explode("\n", $text);
+
+        # iterate through lines to identify blocks
+        return $this->linesElements($lines);
+    }
+
+    #
+    # Setters
+    #
+
+    function setBreaksEnabled($breaksEnabled)
+    {
+        $this->breaksEnabled = $breaksEnabled;
+
+        return $this;
+    }
+
+    protected $breaksEnabled;
+
+    function setMarkupEscaped($markupEscaped)
+    {
+        $this->markupEscaped = $markupEscaped;
+
+        return $this;
+    }
+
+    protected $markupEscaped;
+
+    function setUrlsLinked($urlsLinked)
+    {
+        $this->urlsLinked = $urlsLinked;
+
+        return $this;
+    }
+
+    protected $urlsLinked = true;
+
+    function setSafeMode($safeMode)
+    {
+        $this->safeMode = (bool) $safeMode;
+
+        return $this;
+    }
+
+    protected $safeMode;
+
+    function setStrictMode($strictMode)
+    {
+        $this->strictMode = (bool) $strictMode;
+
+        return $this;
+    }
+
+    protected $strictMode;
+
+    protected $safeLinksWhitelist = array(
+        'http://',
+        'https://',
+        'ftp://',
+        'ftps://',
+        'mailto:',
+        'tel:',
+        'data:image/png;base64,',
+        'data:image/gif;base64,',
+        'data:image/jpeg;base64,',
+        'irc:',
+        'ircs:',
+        'git:',
+        'ssh:',
+        'news:',
+        'steam:',
+    );
+
+    #
+    # Lines
+    #
+
+    protected $BlockTypes = array(
+        '#' => array('Header'),
+        '*' => array('Rule', 'List'),
+        '+' => array('List'),
+        '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
+        '0' => array('List'),
+        '1' => array('List'),
+        '2' => array('List'),
+        '3' => array('List'),
+        '4' => array('List'),
+        '5' => array('List'),
+        '6' => array('List'),
+        '7' => array('List'),
+        '8' => array('List'),
+        '9' => array('List'),
+        ':' => array('Table'),
+        '<' => array('Comment', 'Markup'),
+        '=' => array('SetextHeader'),
+        '>' => array('Quote'),
+        '[' => array('Reference'),
+        '_' => array('Rule'),
+        '`' => array('FencedCode'),
+        '|' => array('Table'),
+        '~' => array('FencedCode'),
+    );
+
+    # ~
+
+    protected $unmarkedBlockTypes = array(
+        'Code',
+    );
+
+    #
+    # Blocks
+    #
+
+    protected function lines(array $lines)
+    {
+        return $this->elements($this->linesElements($lines));
+    }
+
+    protected function linesElements(array $lines)
+    {
+        $Elements = array();
+        $CurrentBlock = null;
+
+        foreach ($lines as $line)
+        {
+            if (chop($line) === '')
+            {
+                if (isset($CurrentBlock))
+                {
+                    $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted'])
+                        ? $CurrentBlock['interrupted'] + 1 : 1
+                    );
+                }
+
+                continue;
+            }
+
+            while (($beforeTab = strstr($line, "\t", true)) !== false)
+            {
+                $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4;
+
+                $line = $beforeTab
+                    . str_repeat(' ', $shortage)
+                    . substr($line, strlen($beforeTab) + 1)
+                ;
+            }
+
+            $indent = strspn($line, ' ');
+
+            $text = $indent > 0 ? substr($line, $indent) : $line;
+
+            # ~
+
+            $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+            # ~
+
+            if (isset($CurrentBlock['continuable']))
+            {
+                $methodName = 'block' . $CurrentBlock['type'] . 'Continue';
+                $Block = $this->$methodName($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $CurrentBlock = $Block;
+
+                    continue;
+                }
+                else
+                {
+                    if ($this->isBlockCompletable($CurrentBlock['type']))
+                    {
+                        $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+                        $CurrentBlock = $this->$methodName($CurrentBlock);
+                    }
+                }
+            }
+
+            # ~
+
+            $marker = $text[0];
+
+            # ~
+
+            $blockTypes = $this->unmarkedBlockTypes;
+
+            if (isset($this->BlockTypes[$marker]))
+            {
+                foreach ($this->BlockTypes[$marker] as $blockType)
+                {
+                    $blockTypes []= $blockType;
+                }
+            }
+
+            #
+            # ~
+
+            foreach ($blockTypes as $blockType)
+            {
+                $Block = $this->{"block$blockType"}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $Block['type'] = $blockType;
+
+                    if ( ! isset($Block['identified']))
+                    {
+                        if (isset($CurrentBlock))
+                        {
+                            $Elements[] = $this->extractElement($CurrentBlock);
+                        }
+
+                        $Block['identified'] = true;
+                    }
+
+                    if ($this->isBlockContinuable($blockType))
+                    {
+                        $Block['continuable'] = true;
+                    }
+
+                    $CurrentBlock = $Block;
+
+                    continue 2;
+                }
+            }
+
+            # ~
+
+            if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph')
+            {
+                $Block = $this->paragraphContinue($Line, $CurrentBlock);
+            }
+
+            if (isset($Block))
+            {
+                $CurrentBlock = $Block;
+            }
+            else
+            {
+                if (isset($CurrentBlock))
+                {
+                    $Elements[] = $this->extractElement($CurrentBlock);
+                }
+
+                $CurrentBlock = $this->paragraph($Line);
+
+                $CurrentBlock['identified'] = true;
+            }
+        }
+
+        # ~
+
+        if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
+        {
+            $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+            $CurrentBlock = $this->$methodName($CurrentBlock);
+        }
+
+        # ~
+
+        if (isset($CurrentBlock))
+        {
+            $Elements[] = $this->extractElement($CurrentBlock);
+        }
+
+        # ~
+
+        return $Elements;
+    }
+
+    protected function extractElement(array $Component)
+    {
+        if ( ! isset($Component['element']))
+        {
+            if (isset($Component['markup']))
+            {
+                $Component['element'] = array('rawHtml' => $Component['markup']);
+            }
+            elseif (isset($Component['hidden']))
+            {
+                $Component['element'] = array();
+            }
+        }
+
+        return $Component['element'];
+    }
+
+    protected function isBlockContinuable($Type)
+    {
+        return method_exists($this, 'block' . $Type . 'Continue');
+    }
+
+    protected function isBlockCompletable($Type)
+    {
+        return method_exists($this, 'block' . $Type . 'Complete');
+    }
+
+    #
+    # Code
+
+    protected function blockCode($Line, $Block = null)
+    {
+        if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] >= 4)
+        {
+            $text = substr($Line['body'], 4);
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'pre',
+                    'element' => array(
+                        'name' => 'code',
+                        'text' => $text,
+                    ),
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeContinue($Line, $Block)
+    {
+        if ($Line['indent'] >= 4)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['element']['text'] .= "\n";
+
+            $text = substr($Line['body'], 4);
+
+            $Block['element']['element']['text'] .= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeComplete($Block)
+    {
+        return $Block;
+    }
+
+    #
+    # Comment
+
+    protected function blockComment($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (strpos($Line['text'], '<!--') === 0)
+        {
+            $Block = array(
+                'element' => array(
+                    'rawHtml' => $Line['body'],
+                    'autobreak' => true,
+                ),
+            );
+
+            if (strpos($Line['text'], '-->') !== false)
+            {
+                $Block['closed'] = true;
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockCommentContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+        if (strpos($Line['text'], '-->') !== false)
+        {
+            $Block['closed'] = true;
+        }
+
+        return $Block;
+    }
+
+    #
+    # Fenced Code
+
+    protected function blockFencedCode($Line)
+    {
+        $marker = $Line['text'][0];
+
+        $openerLength = strspn($Line['text'], $marker);
+
+        if ($openerLength < 3)
+        {
+            return;
+        }
+
+        $infostring = trim(substr($Line['text'], $openerLength), "\t ");
+
+        if (strpos($infostring, '`') !== false)
+        {
+            return;
+        }
+
+        $Element = array(
+            'name' => 'code',
+            'text' => '',
+        );
+
+        if ($infostring !== '')
+        {
+            /**
+             * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
+             * Every HTML element may have a class attribute specified.
+             * The attribute, if specified, must have a value that is a set
+             * of space-separated tokens representing the various classes
+             * that the element belongs to.
+             * [...]
+             * The space characters, for the purposes of this specification,
+             * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
+             * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
+             * U+000D CARRIAGE RETURN (CR).
+             */
+            $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r"));
+
+            $Element['attributes'] = array('class' => "language-$language");
+        }
+
+        $Block = array(
+            'char' => $marker,
+            'openerLength' => $openerLength,
+            'element' => array(
+                'name' => 'pre',
+                'element' => $Element,
+            ),
+        );
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeContinue($Line, $Block)
+    {
+        if (isset($Block['complete']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+            unset($Block['interrupted']);
+        }
+
+        if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength']
+            and chop(substr($Line['text'], $len), ' ') === ''
+        ) {
+            $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1);
+
+            $Block['complete'] = true;
+
+            return $Block;
+        }
+
+        $Block['element']['element']['text'] .= "\n" . $Line['body'];
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeComplete($Block)
+    {
+        return $Block;
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        $level = strspn($Line['text'], '#');
+
+        if ($level > 6)
+        {
+            return;
+        }
+
+        $text = trim($Line['text'], '#');
+
+        if ($this->strictMode and isset($text[0]) and $text[0] !== ' ')
+        {
+            return;
+        }
+
+        $text = trim($text, ' ');
+
+        $Block = array(
+            'element' => array(
+                'name' => 'h' . $level,
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $text,
+                    'destination' => 'elements',
+                )
+            ),
+        );
+
+        return $Block;
+    }
+
+    #
+    # List
+
+    protected function blockList($Line, ?array $CurrentBlock = null)
+    {
+        list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
+
+        if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches))
+        {
+            $contentIndent = strlen($matches[2]);
+
+            if ($contentIndent >= 5)
+            {
+                $contentIndent -= 1;
+                $matches[1] = substr($matches[1], 0, -$contentIndent);
+                $matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
+            }
+            elseif ($contentIndent === 0)
+            {
+                $matches[1] .= ' ';
+            }
+
+            $markerWithoutWhitespace = strstr($matches[1], ' ', true);
+
+            $Block = array(
+                'indent' => $Line['indent'],
+                'pattern' => $pattern,
+                'data' => array(
+                    'type' => $name,
+                    'marker' => $matches[1],
+                    'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
+                ),
+                'element' => array(
+                    'name' => $name,
+                    'elements' => array(),
+                ),
+            );
+            $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
+
+            if ($name === 'ol')
+            {
+                $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
+
+                if ($listStart !== '1')
+                {
+                    if (
+                        isset($CurrentBlock)
+                        and $CurrentBlock['type'] === 'Paragraph'
+                        and ! isset($CurrentBlock['interrupted'])
+                    ) {
+                        return;
+                    }
+
+                    $Block['element']['attributes'] = array('start' => $listStart);
+                }
+            }
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => array(
+                    'function' => 'li',
+                    'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
+                    'destination' => 'elements'
+                )
+            );
+
+            $Block['element']['elements'] []= & $Block['li'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockListContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument']))
+        {
+            return null;
+        }
+
+        $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker']));
+
+        if ($Line['indent'] < $requiredIndent
+            and (
+                (
+                    $Block['data']['type'] === 'ol'
+                    and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+                ) or (
+                    $Block['data']['type'] === 'ul'
+                    and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+                )
+            )
+        ) {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['handler']['argument'] []= '';
+
+                $Block['loose'] = true;
+
+                unset($Block['interrupted']);
+            }
+
+            unset($Block['li']);
+
+            $text = isset($matches[1]) ? $matches[1] : '';
+
+            $Block['indent'] = $Line['indent'];
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => array(
+                    'function' => 'li',
+                    'argument' => array($text),
+                    'destination' => 'elements'
+                )
+            );
+
+            $Block['element']['elements'] []= & $Block['li'];
+
+            return $Block;
+        }
+        elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line))
+        {
+            return null;
+        }
+
+        if ($Line['text'][0] === '[' and $this->blockReference($Line))
+        {
+            return $Block;
+        }
+
+        if ($Line['indent'] >= $requiredIndent)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['handler']['argument'] []= '';
+
+                $Block['loose'] = true;
+
+                unset($Block['interrupted']);
+            }
+
+            $text = substr($Line['body'], $requiredIndent);
+
+            $Block['li']['handler']['argument'] []= $text;
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
+
+            $Block['li']['handler']['argument'] []= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockListComplete(array $Block)
+    {
+        if (isset($Block['loose']))
+        {
+            foreach ($Block['element']['elements'] as &$li)
+            {
+                if (end($li['handler']['argument']) !== '')
+                {
+                    $li['handler']['argument'] []= '';
+                }
+            }
+        }
+
+        return $Block;
+    }
+
+    #
+    # Quote
+
+    protected function blockQuote($Line)
+    {
+        if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'blockquote',
+                    'handler' => array(
+                        'function' => 'linesElements',
+                        'argument' => (array) $matches[1],
+                        'destination' => 'elements',
+                    )
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockQuoteContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+        {
+            $Block['element']['handler']['argument'] []= $matches[1];
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $Block['element']['handler']['argument'] []= $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    #
+    # Rule
+
+    protected function blockRule($Line)
+    {
+        $marker = $Line['text'][0];
+
+        if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '')
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'hr',
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, ?array $Block = null)
+    {
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '')
+        {
+            $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
+
+            return $Block;
+        }
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkup($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches))
+        {
+            $element = strtolower($matches[1]);
+
+            if (in_array($element, $this->textLevelElements))
+            {
+                return;
+            }
+
+            $Block = array(
+                'name' => $matches[1],
+                'element' => array(
+                    'rawHtml' => $Line['text'],
+                    'autobreak' => true,
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockMarkupContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+        return $Block;
+    }
+
+    #
+    # Reference
+
+    protected function blockReference($Line)
+    {
+        if (strpos($Line['text'], ']') !== false
+            and preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches)
+        ) {
+            $id = strtolower($matches[1]);
+
+            $Data = array(
+                'url' => $matches[2],
+                'title' => isset($matches[3]) ? $matches[3] : null,
+            );
+
+            $this->DefinitionData['Reference'][$id] = $Data;
+
+            $Block = array(
+                'element' => array(),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Table
+
+    protected function blockTable($Line, ?array $Block = null)
+    {
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (
+            strpos($Block['element']['handler']['argument'], '|') === false
+            and strpos($Line['text'], '|') === false
+            and strpos($Line['text'], ':') === false
+            or strpos($Block['element']['handler']['argument'], "\n") !== false
+        ) {
+            return;
+        }
+
+        if (chop($Line['text'], ' -:|') !== '')
+        {
+            return;
+        }
+
+        $alignments = array();
+
+        $divider = $Line['text'];
+
+        $divider = trim($divider);
+        $divider = trim($divider, '|');
+
+        $dividerCells = explode('|', $divider);
+
+        foreach ($dividerCells as $dividerCell)
+        {
+            $dividerCell = trim($dividerCell);
+
+            if ($dividerCell === '')
+            {
+                return;
+            }
+
+            $alignment = null;
+
+            if ($dividerCell[0] === ':')
+            {
+                $alignment = 'left';
+            }
+
+            if (substr($dividerCell, - 1) === ':')
+            {
+                $alignment = $alignment === 'left' ? 'center' : 'right';
+            }
+
+            $alignments []= $alignment;
+        }
+
+        # ~
+
+        $HeaderElements = array();
+
+        $header = $Block['element']['handler']['argument'];
+
+        $header = trim($header);
+        $header = trim($header, '|');
+
+        $headerCells = explode('|', $header);
+
+        if (count($headerCells) !== count($alignments))
+        {
+            return;
+        }
+
+        foreach ($headerCells as $index => $headerCell)
+        {
+            $headerCell = trim($headerCell);
+
+            $HeaderElement = array(
+                'name' => 'th',
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $headerCell,
+                    'destination' => 'elements',
+                )
+            );
+
+            if (isset($alignments[$index]))
+            {
+                $alignment = $alignments[$index];
+
+                $HeaderElement['attributes'] = array(
+                    'style' => "text-align: $alignment;",
+                );
+            }
+
+            $HeaderElements []= $HeaderElement;
+        }
+
+        # ~
+
+        $Block = array(
+            'alignments' => $alignments,
+            'identified' => true,
+            'element' => array(
+                'name' => 'table',
+                'elements' => array(),
+            ),
+        );
+
+        $Block['element']['elements'] []= array(
+            'name' => 'thead',
+        );
+
+        $Block['element']['elements'] []= array(
+            'name' => 'tbody',
+            'elements' => array(),
+        );
+
+        $Block['element']['elements'][0]['elements'] []= array(
+            'name' => 'tr',
+            'elements' => $HeaderElements,
+        );
+
+        return $Block;
+    }
+
+    protected function blockTableContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|'))
+        {
+            $Elements = array();
+
+            $row = $Line['text'];
+
+            $row = trim($row);
+            $row = trim($row, '|');
+
+            preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
+
+            $cells = array_slice($matches[0], 0, count($Block['alignments']));
+
+            foreach ($cells as $index => $cell)
+            {
+                $cell = trim($cell);
+
+                $Element = array(
+                    'name' => 'td',
+                    'handler' => array(
+                        'function' => 'lineElements',
+                        'argument' => $cell,
+                        'destination' => 'elements',
+                    )
+                );
+
+                if (isset($Block['alignments'][$index]))
+                {
+                    $Element['attributes'] = array(
+                        'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
+                    );
+                }
+
+                $Elements []= $Element;
+            }
+
+            $Element = array(
+                'name' => 'tr',
+                'elements' => $Elements,
+            );
+
+            $Block['element']['elements'][1]['elements'] []= $Element;
+
+            return $Block;
+        }
+    }
+
+    #
+    # ~
+    #
+
+    protected function paragraph($Line)
+    {
+        return array(
+            'type' => 'Paragraph',
+            'element' => array(
+                'name' => 'p',
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $Line['text'],
+                    'destination' => 'elements',
+                ),
+            ),
+        );
+    }
+
+    protected function paragraphContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        $Block['element']['handler']['argument'] .= "\n".$Line['text'];
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    protected $InlineTypes = array(
+        '!' => array('Image'),
+        '&' => array('SpecialCharacter'),
+        '*' => array('Emphasis'),
+        ':' => array('Url'),
+        '<' => array('UrlTag', 'EmailTag', 'Markup'),
+        '[' => array('Link'),
+        '_' => array('Emphasis'),
+        '`' => array('Code'),
+        '~' => array('Strikethrough'),
+        '\\' => array('EscapeSequence'),
+    );
+
+    # ~
+
+    protected $inlineMarkerList = '!*_&[:<`~\\';
+
+    #
+    # ~
+    #
+
+    public function line($text, $nonNestables = array())
+    {
+        return $this->elements($this->lineElements($text, $nonNestables));
+    }
+
+    protected function lineElements($text, $nonNestables = array())
+    {
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        $Elements = array();
+
+        $nonNestables = (empty($nonNestables)
+            ? array()
+            : array_combine($nonNestables, $nonNestables)
+        );
+
+        # $excerpt is based on the first occurrence of a marker
+
+        while ($excerpt = strpbrk($text, $this->inlineMarkerList))
+        {
+            $marker = $excerpt[0];
+
+            $markerPosition = strlen($text) - strlen($excerpt);
+
+            $Excerpt = array('text' => $excerpt, 'context' => $text);
+
+            foreach ($this->InlineTypes[$marker] as $inlineType)
+            {
+                # check to see if the current inline type is nestable in the current context
+
+                if (isset($nonNestables[$inlineType]))
+                {
+                    continue;
+                }
+
+                $Inline = $this->{"inline$inlineType"}($Excerpt);
+
+                if ( ! isset($Inline))
+                {
+                    continue;
+                }
+
+                # makes sure that the inline belongs to "our" marker
+
+                if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
+                {
+                    continue;
+                }
+
+                # sets a default inline position
+
+                if ( ! isset($Inline['position']))
+                {
+                    $Inline['position'] = $markerPosition;
+                }
+
+                # cause the new element to 'inherit' our non nestables
+
+
+                $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables'])
+                    ? array_merge($Inline['element']['nonNestables'], $nonNestables)
+                    : $nonNestables
+                ;
+
+                # the text that comes before the inline
+                $unmarkedText = substr($text, 0, $Inline['position']);
+
+                # compile the unmarked text
+                $InlineText = $this->inlineText($unmarkedText);
+                $Elements[] = $InlineText['element'];
+
+                # compile the inline
+                $Elements[] = $this->extractElement($Inline);
+
+                # remove the examined text
+                $text = substr($text, $Inline['position'] + $Inline['extent']);
+
+                continue 2;
+            }
+
+            # the marker does not belong to an inline
+
+            $unmarkedText = substr($text, 0, $markerPosition + 1);
+
+            $InlineText = $this->inlineText($unmarkedText);
+            $Elements[] = $InlineText['element'];
+
+            $text = substr($text, $markerPosition + 1);
+        }
+
+        $InlineText = $this->inlineText($text);
+        $Elements[] = $InlineText['element'];
+
+        foreach ($Elements as &$Element)
+        {
+            if ( ! isset($Element['autobreak']))
+            {
+                $Element['autobreak'] = false;
+            }
+        }
+
+        return $Elements;
+    }
+
+    #
+    # ~
+    #
+
+    protected function inlineText($text)
+    {
+        $Inline = array(
+            'extent' => strlen($text),
+            'element' => array(),
+        );
+
+        $Inline['element']['elements'] = self::pregReplaceElements(
+            $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
+            array(
+                array('name' => 'br'),
+                array('text' => "\n"),
+            ),
+            $text
+        );
+
+        return $Inline;
+    }
+
+    protected function inlineCode($Excerpt)
+    {
+        $marker = $Excerpt['text'][0];
+
+        if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(?<!['.$marker.'])\1(?!'.$marker.')/s', $Excerpt['text'], $matches))
+        {
+            $text = $matches[2];
+            $text = preg_replace('/[ ]*+\n/', ' ', $text);
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'code',
+                    'text' => $text,
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmailTag($Excerpt)
+    {
+        $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
+
+        $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@'
+            . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
+
+        if (strpos($Excerpt['text'], '>') !== false
+            and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches)
+        ){
+            $url = $matches[1];
+
+            if ( ! isset($matches[2]))
+            {
+                $url = "mailto:$url";
+            }
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $matches[1],
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmphasis($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        $marker = $Excerpt['text'][0];
+
+        if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'strong';
+        }
+        elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'em';
+        }
+        else
+        {
+            return;
+        }
+
+        return array(
+            'extent' => strlen($matches[0]),
+            'element' => array(
+                'name' => $emphasis,
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $matches[1],
+                    'destination' => 'elements',
+                )
+            ),
+        );
+    }
+
+    protected function inlineEscapeSequence($Excerpt)
+    {
+        if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
+        {
+            return array(
+                'element' => array('rawHtml' => $Excerpt['text'][1]),
+                'extent' => 2,
+            );
+        }
+    }
+
+    protected function inlineImage($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
+        {
+            return;
+        }
+
+        $Excerpt['text']= substr($Excerpt['text'], 1);
+
+        $Link = $this->inlineLink($Excerpt);
+
+        if ($Link === null)
+        {
+            return;
+        }
+
+        $Inline = array(
+            'extent' => $Link['extent'] + 1,
+            'element' => array(
+                'name' => 'img',
+                'attributes' => array(
+                    'src' => $Link['element']['attributes']['href'],
+                    'alt' => $Link['element']['handler']['argument'],
+                ),
+                'autobreak' => true,
+            ),
+        );
+
+        $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+        unset($Inline['element']['attributes']['href']);
+
+        return $Inline;
+    }
+
+    protected function inlineLink($Excerpt)
+    {
+        $Element = array(
+            'name' => 'a',
+            'handler' => array(
+                'function' => 'lineElements',
+                'argument' => null,
+                'destination' => 'elements',
+            ),
+            'nonNestables' => array('Url', 'Link'),
+            'attributes' => array(
+                'href' => null,
+                'title' => null,
+            ),
+        );
+
+        $extent = 0;
+
+        $remainder = $Excerpt['text'];
+
+        if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
+        {
+            $Element['handler']['argument'] = $matches[1];
+
+            $extent += strlen($matches[0]);
+
+            $remainder = substr($remainder, $extent);
+        }
+        else
+        {
+            return;
+        }
+
+        if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches))
+        {
+            $Element['attributes']['href'] = $matches[1];
+
+            if (isset($matches[2]))
+            {
+                $Element['attributes']['title'] = substr($matches[2], 1, - 1);
+            }
+
+            $extent += strlen($matches[0]);
+        }
+        else
+        {
+            if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
+            {
+                $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument'];
+                $definition = strtolower($definition);
+
+                $extent += strlen($matches[0]);
+            }
+            else
+            {
+                $definition = strtolower($Element['handler']['argument']);
+            }
+
+            if ( ! isset($this->DefinitionData['Reference'][$definition]))
+            {
+                return;
+            }
+
+            $Definition = $this->DefinitionData['Reference'][$definition];
+
+            $Element['attributes']['href'] = $Definition['url'];
+            $Element['attributes']['title'] = $Definition['title'];
+        }
+
+        return array(
+            'extent' => $extent,
+            'element' => $Element,
+        );
+    }
+
+    protected function inlineMarkup($Excerpt)
+    {
+        if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+    }
+
+    protected function inlineSpecialCharacter($Excerpt)
+    {
+        if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false
+            and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
+        ) {
+            return array(
+                'element' => array('rawHtml' => '&' . $matches[1] . ';'),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        return;
+    }
+
+    protected function inlineStrikethrough($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
+        {
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'del',
+                    'handler' => array(
+                        'function' => 'lineElements',
+                        'argument' => $matches[1],
+                        'destination' => 'elements',
+                    )
+                ),
+            );
+        }
+    }
+
+    protected function inlineUrl($Excerpt)
+    {
+        if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
+        {
+            return;
+        }
+
+        if (strpos($Excerpt['context'], 'http') !== false
+            and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)
+        ) {
+            $url = $matches[0][0];
+
+            $Inline = array(
+                'extent' => strlen($matches[0][0]),
+                'position' => $matches[0][1],
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+
+            return $Inline;
+        }
+    }
+
+    protected function inlineUrlTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches))
+        {
+            $url = $matches[1];
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    # ~
+
+    protected function unmarkedText($text)
+    {
+        $Inline = $this->inlineText($text);
+        return $this->element($Inline['element']);
+    }
+
+    #
+    # Handlers
+    #
+
+    protected function handle(array $Element)
+    {
+        if (isset($Element['handler']))
+        {
+            if (!isset($Element['nonNestables']))
+            {
+                $Element['nonNestables'] = array();
+            }
+
+            if (is_string($Element['handler']))
+            {
+                $function = $Element['handler'];
+                $argument = $Element['text'];
+                unset($Element['text']);
+                $destination = 'rawHtml';
+            }
+            else
+            {
+                $function = $Element['handler']['function'];
+                $argument = $Element['handler']['argument'];
+                $destination = $Element['handler']['destination'];
+            }
+
+            $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
+
+            if ($destination === 'handler')
+            {
+                $Element = $this->handle($Element);
+            }
+
+            unset($Element['handler']);
+        }
+
+        return $Element;
+    }
+
+    protected function handleElementRecursive(array $Element)
+    {
+        return $this->elementApplyRecursive(array($this, 'handle'), $Element);
+    }
+
+    protected function handleElementsRecursive(array $Elements)
+    {
+        return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
+    }
+
+    protected function elementApplyRecursive($closure, array $Element)
+    {
+        $Element = call_user_func($closure, $Element);
+
+        if (isset($Element['elements']))
+        {
+            $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
+        }
+        elseif (isset($Element['element']))
+        {
+            $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
+        }
+
+        return $Element;
+    }
+
+    protected function elementApplyRecursiveDepthFirst($closure, array $Element)
+    {
+        if (isset($Element['elements']))
+        {
+            $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
+        }
+        elseif (isset($Element['element']))
+        {
+            $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
+        }
+
+        $Element = call_user_func($closure, $Element);
+
+        return $Element;
+    }
+
+    protected function elementsApplyRecursive($closure, array $Elements)
+    {
+        foreach ($Elements as &$Element)
+        {
+            $Element = $this->elementApplyRecursive($closure, $Element);
+        }
+
+        return $Elements;
+    }
+
+    protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
+    {
+        foreach ($Elements as &$Element)
+        {
+            $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
+        }
+
+        return $Elements;
+    }
+
+    protected function element(array $Element)
+    {
+        if ($this->safeMode)
+        {
+            $Element = $this->sanitiseElement($Element);
+        }
+
+        # identity map if element has no handler
+        $Element = $this->handle($Element);
+
+        $hasName = isset($Element['name']);
+
+        $markup = '';
+
+        if ($hasName)
+        {
+            $markup .= '<' . $Element['name'];
+
+            if (isset($Element['attributes']))
+            {
+                foreach ($Element['attributes'] as $name => $value)
+                {
+                    if ($value === null)
+                    {
+                        continue;
+                    }
+
+                    $markup .= " $name=\"".self::escape($value).'"';
+                }
+            }
+        }
+
+        $permitRawHtml = false;
+
+        if (isset($Element['text']))
+        {
+            $text = $Element['text'];
+        }
+        // very strongly consider an alternative if you're writing an
+        // extension
+        elseif (isset($Element['rawHtml']))
+        {
+            $text = $Element['rawHtml'];
+
+            $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
+            $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
+        }
+
+        $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
+
+        if ($hasContent)
+        {
+            $markup .= $hasName ? '>' : '';
+
+            if (isset($Element['elements']))
+            {
+                $markup .= $this->elements($Element['elements']);
+            }
+            elseif (isset($Element['element']))
+            {
+                $markup .= $this->element($Element['element']);
+            }
+            else
+            {
+                if (!$permitRawHtml)
+                {
+                    $markup .= self::escape($text, true);
+                }
+                else
+                {
+                    $markup .= $text;
+                }
+            }
+
+            $markup .= $hasName ? '</' . $Element['name'] . '>' : '';
+        }
+        elseif ($hasName)
+        {
+            $markup .= ' />';
+        }
+
+        return $markup;
+    }
+
+    protected function elements(array $Elements)
+    {
+        $markup = '';
+
+        $autoBreak = true;
+
+        foreach ($Elements as $Element)
+        {
+            if (empty($Element))
+            {
+                continue;
+            }
+
+            $autoBreakNext = (isset($Element['autobreak'])
+                ? $Element['autobreak'] : isset($Element['name'])
+            );
+            // (autobreak === false) covers both sides of an element
+            $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
+
+            $markup .= ($autoBreak ? "\n" : '') . $this->element($Element);
+            $autoBreak = $autoBreakNext;
+        }
+
+        $markup .= $autoBreak ? "\n" : '';
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function li($lines)
+    {
+        $Elements = $this->linesElements($lines);
+
+        if ( ! in_array('', $lines)
+            and isset($Elements[0]) and isset($Elements[0]['name'])
+            and $Elements[0]['name'] === 'p'
+        ) {
+            unset($Elements[0]['name']);
+        }
+
+        return $Elements;
+    }
+
+    #
+    # AST Convenience
+    #
+
+    /**
+     * Replace occurrences $regexp with $Elements in $text. Return an array of
+     * elements representing the replacement.
+     */
+    protected static function pregReplaceElements($regexp, $Elements, $text)
+    {
+        $newElements = array();
+
+        while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE))
+        {
+            $offset = $matches[0][1];
+            $before = substr($text, 0, $offset);
+            $after = substr($text, $offset + strlen($matches[0][0]));
+
+            $newElements[] = array('text' => $before);
+
+            foreach ($Elements as $Element)
+            {
+                $newElements[] = $Element;
+            }
+
+            $text = $after;
+        }
+
+        $newElements[] = array('text' => $text);
+
+        return $newElements;
+    }
+
+    #
+    # Deprecated Methods
+    #
+
+    function parse($text)
+    {
+        $markup = $this->text($text);
+
+        return $markup;
+    }
+
+    protected function sanitiseElement(array $Element)
+    {
+        static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
+        static $safeUrlNameToAtt  = array(
+            'a'   => 'href',
+            'img' => 'src',
+        );
+
+        if ( ! isset($Element['name']))
+        {
+            unset($Element['attributes']);
+            return $Element;
+        }
+
+        if (isset($safeUrlNameToAtt[$Element['name']]))
+        {
+            $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
+        }
+
+        if ( ! empty($Element['attributes']))
+        {
+            foreach ($Element['attributes'] as $att => $val)
+            {
+                # filter out badly parsed attribute
+                if ( ! preg_match($goodAttribute, $att))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+                # dump onevent attribute
+                elseif (self::striAtStart($att, 'on'))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+            }
+        }
+
+        return $Element;
+    }
+
+    protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
+    {
+        foreach ($this->safeLinksWhitelist as $scheme)
+        {
+            if (self::striAtStart($Element['attributes'][$attribute], $scheme))
+            {
+                return $Element;
+            }
+        }
+
+        $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
+
+        return $Element;
+    }
+
+    #
+    # Static Methods
+    #
+
+    protected static function escape($text, $allowQuotes = false)
+    {
+        return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
+    }
+
+    protected static function striAtStart($string, $needle)
+    {
+        $len = strlen($needle);
+
+        if ($len > strlen($string))
+        {
+            return false;
+        }
+        else
+        {
+            return strtolower(substr($string, 0, $len)) === strtolower($needle);
+        }
+    }
+
+    static function instance($name = 'default')
+    {
+        if (isset(self::$instances[$name]))
+        {
+            return self::$instances[$name];
+        }
+
+        $instance = new static();
+
+        self::$instances[$name] = $instance;
+
+        return $instance;
+    }
+
+    private static $instances = array();
+
+    #
+    # Fields
+    #
+
+    protected $DefinitionData;
+
+    #
+    # Read-Only
+
+    protected $specialCharacters = array(
+        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~'
+    );
+
+    protected $StrongRegex = array(
+        '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
+        '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us',
+    );
+
+    protected $EmRegex = array(
+        '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
+        '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
+    );
+
+    protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
+
+    protected $voidElements = array(
+        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
+    );
+
+    protected $textLevelElements = array(
+        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
+        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
+        'i', 'rp', 'del', 'code',          'strike', 'marquee',
+        'q', 'rt', 'ins', 'font',          'strong',
+        's', 'tt', 'kbd', 'mark',
+        'u', 'xm', 'sub', 'nobr',
+                   'sup', 'ruby',
+                   'var', 'span',
+                   'wbr', 'time',
+    );
+}
diff --git a/app/models/Git_Model.php b/app/models/Git_Model.php
new file mode 100644
index 0000000..7c89afa
--- /dev/null
+++ b/app/models/Git_Model.php
@@ -0,0 +1,206 @@
+<?php
+
+class Git_Model {
+    private static function repo_root(): string {
+        return $_SERVER['GIT_ROOT'] ?? getenv('GIT_ROOT') ?: '/srv/git';
+    }
+
+    public static function list_repos(): array {
+        $repos = [];
+
+        foreach (glob(self::repo_root() . '/*.git', GLOB_ONLYDIR) as $path) {
+            $name = basename($path);
+            $desc = @file_get_contents("$path/description") ?: '';
+            if (str_contains($desc, 'Unnamed repository')) {
+                $desc = '';
+            }
+
+            $last_commit = self::run($name, 'log -1 --format=%ct') ?: '0';
+
+            $repos[] = [
+                'name' => $name,
+                'description' => trim($desc),
+                'last_commit' => (int)$last_commit,
+            ];
+        }
+
+        usort($repos, fn($a, $b) => $b['last_commit'] <=> $a['last_commit']);
+        return $repos;
+    }
+
+    public static function get_repo_info(string $repo): ?array {
+        $path = self::repo_path($repo);
+        if (!is_dir($path)) {
+            return null;
+        }
+
+        $desc = @file_get_contents("$path/description") ?: '';
+        if (str_contains($desc, 'Unnamed repository')) {
+            $desc = '';
+        }
+
+        $head = trim(self::run($repo, 'rev-parse --abbrev-ref HEAD') ?: 'master');
+        $branches = self::get_branches($repo);
+        $tags = self::get_tags($repo);
+
+        return [
+            'name' => $repo,
+            'description' => trim($desc),
+            'default_branch' => $head,
+            'branches' => $branches,
+            'tags' => $tags,
+        ];
+    }
+
+    public static function get_commits(string $repo, string $ref = 'HEAD', int $limit = 50, int $offset = 0): array {
+        $format = '%H%x00%s%x00%an%x00%ae%x00%at';
+        $output = self::run($repo, "log --skip=$offset -n $limit --format='$format' " . escapeshellarg($ref));
+
+        if (!$output) return [];
+
+        $commits = [];
+        foreach (explode("\n", trim($output)) as $line) {
+            if (!$line) continue;
+            [$hash, $subject, $author_name, $author_email, $timestamp] = explode("\x00", $line);
+            $commits[] = [
+                'hash' => $hash,
+                'short_hash' => substr($hash, 0, 7),
+                'subject' => $subject,
+                'author_name' => $author_name,
+                'author_email' => $author_email,
+                'date' => (int)$timestamp,
+            ];
+        }
+
+        return $commits;
+    }
+
+    public static function get_commit(string $repo, string $hash): ?array {
+        $format = '%H%x00%s%x00%b%x00%an%x00%ae%x00%at%x00%P';
+        $output = self::run($repo, "show -s --format='$format' " . escapeshellarg($hash));
+
+        if (!$output) return null;
+
+        [$hash, $subject, $body, $author_name, $author_email, $timestamp, $parents] = explode("\x00", trim($output));
+
+        return [
+            'hash' => $hash,
+            'short_hash' => substr($hash, 0, 7),
+            'subject' => $subject,
+            'body' => trim($body),
+            'author_name' => $author_name,
+            'author_email' => $author_email,
+            'date' => (int)$timestamp,
+            'parents' => $parents ? explode(' ', $parents) : [],
+        ];
+    }
+
+    public static function get_diff(string $repo, string $hash): string {
+        return self::run($repo, "show --format='' --patch " . escapeshellarg($hash)) ?: '';
+    }
+
+    public static function get_tree(string $repo, string $ref = 'HEAD', string $path = ''): array {
+        $treeish = $path ? "$ref:$path" : $ref;
+        $output = self::run($repo, 'ls-tree ' . escapeshellarg($treeish));
+
+        if (!$output) return [];
+
+        $entries = [];
+        foreach (explode("\n", trim($output)) as $line) {
+            if (!$line) continue;
+            if (preg_match('/^(\d+)\s+(\w+)\s+([a-f0-9]+)\t(.+)$/', $line, $m)) {
+                $entries[] = [
+                    'mode' => $m[1],
+                    'type' => $m[2],
+                    'hash' => $m[3],
+                    'name' => $m[4],
+                ];
+            }
+        }
+
+        usort($entries, function($a, $b) {
+            if (($a['type'] === 'tree') !== ($b['type'] === 'tree')) {
+                return $a['type'] === 'tree' ? -1 : 1;
+            }
+            return strcasecmp($a['name'], $b['name']);
+        });
+
+        return $entries;
+    }
+
+    public static function get_blob(string $repo, string $ref, string $path): ?string {
+        return self::run($repo, 'show ' . escapeshellarg("$ref:$path"));
+    }
+
+    public static function get_blob_size(string $repo, string $ref, string $path): int {
+        $size = self::run($repo, 'cat-file -s ' . escapeshellarg("$ref:$path"));
+        return $size ? (int)$size : 0;
+    }
+
+    public static function get_branches(string $repo): array {
+        $output = self::run($repo, 'branch --format="%(refname:short)"');
+        return $output ? array_filter(explode("\n", trim($output))) : [];
+    }
+
+    public static function get_tags(string $repo): array {
+        $output = self::run($repo, 'tag --sort=-creatordate');
+        return $output ? array_filter(explode("\n", trim($output))) : [];
+    }
+
+    public static function get_refs(string $repo): array {
+        return [
+            'branches' => self::get_branches($repo),
+            'tags' => self::get_tags($repo),
+        ];
+    }
+
+    public static function get_readme(string $repo, string $ref = 'HEAD'): ?array {
+        $tree = self::get_tree($repo, $ref);
+        $readme_names = ['README.md', 'README', 'README.txt', 'readme.md'];
+
+        foreach ($tree as $entry) {
+            if ($entry['type'] === 'blob' && in_array($entry['name'], $readme_names)) {
+                $content = self::get_blob($repo, $ref, $entry['name']);
+                return [
+                    'name' => $entry['name'],
+                    'content' => $content,
+                ];
+            }
+        }
+
+        return null;
+    }
+
+    public static function resolve_ref(string $repo, string $ref): ?string {
+        $hash = self::run($repo, 'rev-parse ' . escapeshellarg($ref));
+        return $hash ? trim($hash) : null;
+    }
+
+    public static function get_clone_urls(string $repo): array {
+        $name = basename($repo, '.git') . '.git';
+        return [
+            'https' => "https://git.tonybtw.com/$name",
+            'git' => "git://git.tonybtw.com/$name",
+        ];
+    }
+
+    private static function run(string $repo, string $cmd): ?string {
+        $repo_path = self::repo_path($repo);
+        if (!is_dir($repo_path)) {
+            return null;
+        }
+
+        $full_cmd = sprintf('git -C %s %s 2>/dev/null', escapeshellarg($repo_path), $cmd);
+        $output = shell_exec($full_cmd);
+
+        return $output !== null ? rtrim($output, "\n") : null;
+    }
+
+    private static function repo_path(string $repo): string {
+        $repo = basename($repo);
+        if (!str_ends_with($repo, '.git')) {
+            $repo .= '.git';
+        }
+        return self::repo_root() . '/' . $repo;
+    }
+}
diff --git a/app/views/error.php b/app/views/error.php
new file mode 100644
index 0000000..15dcc28
--- /dev/null
+++ b/app/views/error.php
@@ -0,0 +1,9 @@
+<?php $title = 'Error'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<div class="error">
+    <h1><?= htmlspecialchars($error) ?></h1>
+    <p><a href="/">Back to repositories</a></p>
+</div>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/home.php b/app/views/home.php
new file mode 100644
index 0000000..9650920
--- /dev/null
+++ b/app/views/home.php
@@ -0,0 +1,25 @@
+<?php $title = "Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<h1>Repositories</h1>
+
+<table class="repo-list">
+    <thead>
+        <tr>
+            <th>Name</th>
+            <th>Description</th>
+            <th>Last commit</th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($repos as $r): ?>
+        <tr>
+            <td><a href="/<?= htmlspecialchars($r['name']) ?>"><?= htmlspecialchars(basename($r['name'], '.git')) ?></a></td>
+            <td><?= htmlspecialchars($r['description']) ?></td>
+            <td><?= $r['last_commit'] ? date('Y-m-d H:i', $r['last_commit']) : '-' ?></td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+</table>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/partials/footer.php b/app/views/partials/footer.php
new file mode 100644
index 0000000..6e9de93
--- /dev/null
+++ b/app/views/partials/footer.php
@@ -0,0 +1,8 @@
+</main>
+<footer>
+    <p>git-btw</p>
+</footer>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+<script>hljs.highlightAll();</script>
+</body>
+</html>
diff --git a/app/views/partials/header.php b/app/views/partials/header.php
new file mode 100644
index 0000000..95afb06
--- /dev/null
+++ b/app/views/partials/header.php
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?= htmlspecialchars($title ?? "Tony's Git") ?></title>
+    <link rel="stylesheet" href="/css/style.css">
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
+</head>
+<body>
+<header>
+    <nav>
+        <a href="/" class="logo">git.tonybtw.com</a>
+    </nav>
+</header>
+<main>
diff --git a/app/views/partials/repo_nav.php b/app/views/partials/repo_nav.php
new file mode 100644
index 0000000..0cc4e54
--- /dev/null
+++ b/app/views/partials/repo_nav.php
@@ -0,0 +1,18 @@
+<div class="repo-header">
+    <h1><a href="/<?= htmlspecialchars($repo['name']) ?>"><?= htmlspecialchars(basename($repo['name'], '.git')) ?></a></h1>
+    <?php if ($repo['description']): ?>
+    <p class="description"><?= htmlspecialchars($repo['description']) ?></p>
+    <?php endif; ?>
+</div>
+
+<nav class="repo-nav">
+    <a href="/<?= htmlspecialchars($repo['name']) ?>">summary</a>
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref ?? $repo['default_branch']) ?>">tree</a>
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/log/<?= htmlspecialchars($ref ?? $repo['default_branch']) ?>">log</a>
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/refs">refs</a>
+</nav>
+
+<div class="clone-urls">
+    <code><?= htmlspecialchars($clone_urls['https']) ?></code>
+    <code><?= htmlspecialchars($clone_urls['git']) ?></code>
+</div>
diff --git a/app/views/repo/blob.php b/app/views/repo/blob.php
new file mode 100644
index 0000000..937048c
--- /dev/null
+++ b/app/views/repo/blob.php
@@ -0,0 +1,92 @@
+<?php $title = basename($path) . ' - ' . basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<?php
+$path_parts = explode('/', $path);
+$filename = array_pop($path_parts);
+$ext = pathinfo($filename, PATHINFO_EXTENSION);
+
+$lang_map = [
+    'php' => 'php',
+    'js' => 'javascript',
+    'ts' => 'typescript',
+    'tsx' => 'typescript',
+    'jsx' => 'javascript',
+    'py' => 'python',
+    'rb' => 'ruby',
+    'go' => 'go',
+    'rs' => 'rust',
+    'c' => 'c',
+    'h' => 'c',
+    'cpp' => 'cpp',
+    'hpp' => 'cpp',
+    'java' => 'java',
+    'sh' => 'bash',
+    'bash' => 'bash',
+    'zsh' => 'bash',
+    'json' => 'json',
+    'yaml' => 'yaml',
+    'yml' => 'yaml',
+    'toml' => 'toml',
+    'xml' => 'xml',
+    'html' => 'html',
+    'css' => 'css',
+    'scss' => 'scss',
+    'sql' => 'sql',
+    'md' => 'markdown',
+    'nix' => 'nix',
+    'zig' => 'zig',
+    'lua' => 'lua',
+    'vim' => 'vim',
+    'dockerfile' => 'dockerfile',
+    'makefile' => 'makefile',
+];
+$lang = $lang_map[strtolower($ext)] ?? '';
+
+$is_binary = preg_match('/[\x00-\x08\x0E-\x1F]/', substr($content, 0, 8192));
+$is_markdown = strtolower($ext) === 'md';
+$lines = $is_binary ? [] : explode("\n", $content);
+?>
+
+<div class="breadcrumb">
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?>"><?= htmlspecialchars(basename($repo['name'], '.git')) ?></a>
+    <?php
+    $accumulated = '';
+    foreach ($path_parts as $part):
+        $accumulated .= '/' . $part;
+    ?>
+    / <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?><?= htmlspecialchars($accumulated) ?>"><?= htmlspecialchars($part) ?></a>
+    <?php endforeach; ?>
+    / <?= htmlspecialchars($filename) ?>
+</div>
+
+<div class="blob-meta">
+    <span><?= number_format($size) ?> bytes</span>
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/raw/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($path) ?>">raw</a>
+</div>
+
+<?php if ($is_binary): ?>
+<div class="binary-notice">
+    Binary file not shown. <a href="/<?= htmlspecialchars($repo['name']) ?>/raw/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($path) ?>">Download</a>
+</div>
+<?php elseif ($is_markdown): ?>
+<div class="readme">
+    <div class="markdown-body"><?= $parsedown->text($content) ?></div>
+</div>
+<?php else: ?>
+<div class="blob-content">
+    <table class="code">
+        <tbody>
+            <?php foreach ($lines as $i => $line): ?>
+            <tr id="L<?= $i + 1 ?>">
+                <td class="line-num"><a href="#L<?= $i + 1 ?>"><?= $i + 1 ?></a></td>
+                <td class="line-code"><pre><code class="<?= $lang ? "language-$lang" : '' ?>"><?= htmlspecialchars($line) ?></code></pre></td>
+            </tr>
+            <?php endforeach; ?>
+        </tbody>
+    </table>
+</div>
+<?php endif; ?>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/repo/commit.php b/app/views/repo/commit.php
new file mode 100644
index 0000000..f303be1
--- /dev/null
+++ b/app/views/repo/commit.php
@@ -0,0 +1,38 @@
+<?php $title = $commit['short_hash'] . ' - ' . basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<div class="commit-detail">
+    <h2><?= htmlspecialchars($commit['subject']) ?></h2>
+
+    <dl class="commit-meta">
+        <dt>Commit</dt>
+        <dd><code><?= $commit['hash'] ?></code></dd>
+
+        <?php if ($commit['parents']): ?>
+        <dt>Parent<?= count($commit['parents']) > 1 ? 's' : '' ?></dt>
+        <dd>
+            <?php foreach ($commit['parents'] as $parent): ?>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/commit/<?= $parent ?>"><code><?= substr($parent, 0, 7) ?></code></a>
+            <?php endforeach; ?>
+        </dd>
+        <?php endif; ?>
+
+        <dt>Author</dt>
+        <dd><?= htmlspecialchars($commit['author_name']) ?> &lt;<?= htmlspecialchars($commit['author_email']) ?>&gt;</dd>
+
+        <dt>Date</dt>
+        <dd><?= date('Y-m-d H:i:s', $commit['date']) ?></dd>
+    </dl>
+
+    <?php if ($commit['body']): ?>
+    <pre class="commit-body"><?= htmlspecialchars($commit['body']) ?></pre>
+    <?php endif; ?>
+</div>
+
+<div class="diff-container">
+    <h3>Diff</h3>
+    <pre class="diff"><code class="language-diff"><?= htmlspecialchars($diff) ?></code></pre>
+</div>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/repo/log.php b/app/views/repo/log.php
new file mode 100644
index 0000000..029a573
--- /dev/null
+++ b/app/views/repo/log.php
@@ -0,0 +1,37 @@
+<?php $title = 'Log - ' . basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<h2>Commits on <?= htmlspecialchars($ref) ?></h2>
+
+<table class="commits log-table">
+    <thead>
+        <tr>
+            <th>Hash</th>
+            <th>Subject</th>
+            <th>Author</th>
+            <th>Date</th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($commits as $c): ?>
+        <tr>
+            <td class="hash"><a href="/<?= htmlspecialchars($repo['name']) ?>/commit/<?= $c['hash'] ?>"><?= $c['short_hash'] ?></a></td>
+            <td class="subject"><?= htmlspecialchars($c['subject']) ?></td>
+            <td class="author"><?= htmlspecialchars($c['author_name']) ?></td>
+            <td class="date"><?= date('Y-m-d H:i', $c['date']) ?></td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+</table>
+
+<div class="pagination">
+    <?php if ($page > 1): ?>
+    <a href="?page=<?= $page - 1 ?>">&larr; Newer</a>
+    <?php endif; ?>
+    <?php if (count($commits) === $per_page): ?>
+    <a href="?page=<?= $page + 1 ?>">Older &rarr;</a>
+    <?php endif; ?>
+</div>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/repo/refs.php b/app/views/repo/refs.php
new file mode 100644
index 0000000..696927d
--- /dev/null
+++ b/app/views/repo/refs.php
@@ -0,0 +1,37 @@
+<?php $title = 'Refs - ' . basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<section class="refs-section">
+    <h2>Branches</h2>
+    <?php if ($refs['branches']): ?>
+    <ul class="refs-list">
+        <?php foreach ($refs['branches'] as $branch): ?>
+        <li>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($branch) ?>"><?= htmlspecialchars($branch) ?></a>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/log/<?= htmlspecialchars($branch) ?>" class="ref-link">log</a>
+        </li>
+        <?php endforeach; ?>
+    </ul>
+    <?php else: ?>
+    <p>No branches</p>
+    <?php endif; ?>
+</section>
+
+<section class="refs-section">
+    <h2>Tags</h2>
+    <?php if ($refs['tags']): ?>
+    <ul class="refs-list">
+        <?php foreach ($refs['tags'] as $tag): ?>
+        <li>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($tag) ?>"><?= htmlspecialchars($tag) ?></a>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/log/<?= htmlspecialchars($tag) ?>" class="ref-link">log</a>
+        </li>
+        <?php endforeach; ?>
+    </ul>
+    <?php else: ?>
+    <p>No tags</p>
+    <?php endif; ?>
+</section>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/repo/summary.php b/app/views/repo/summary.php
new file mode 100644
index 0000000..83e4967
--- /dev/null
+++ b/app/views/repo/summary.php
@@ -0,0 +1,51 @@
+<?php $title = basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<section class="summary-section">
+    <h2>Files</h2>
+    <table class="tree">
+        <?php foreach ($tree as $entry): ?>
+        <tr>
+            <td class="mode"><?= $entry['mode'] ?></td>
+            <td class="name">
+                <?php if ($entry['type'] === 'tree'): ?>
+                <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($entry['name']) ?>"><?= htmlspecialchars($entry['name']) ?>/</a>
+                <?php else: ?>
+                <a href="/<?= htmlspecialchars($repo['name']) ?>/blob/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($entry['name']) ?>"><?= htmlspecialchars($entry['name']) ?></a>
+                <?php endif; ?>
+            </td>
+        </tr>
+        <?php endforeach; ?>
+    </table>
+</section>
+
+<section class="summary-section">
+    <h2>Recent commits</h2>
+    <table class="commits">
+        <?php foreach ($commits as $c): ?>
+        <tr>
+            <td class="hash"><a href="/<?= htmlspecialchars($repo['name']) ?>/commit/<?= $c['hash'] ?>"><?= $c['short_hash'] ?></a></td>
+            <td class="subject"><?= htmlspecialchars($c['subject']) ?></td>
+            <td class="author"><?= htmlspecialchars($c['author_name']) ?></td>
+            <td class="date"><?= date('Y-m-d', $c['date']) ?></td>
+        </tr>
+        <?php endforeach; ?>
+    </table>
+    <p><a href="/<?= htmlspecialchars($repo['name']) ?>/log/<?= htmlspecialchars($ref) ?>">View all commits</a></p>
+</section>
+
+<?php if ($readme): ?>
+<section class="summary-section">
+    <h2><?= htmlspecialchars($readme['name']) ?></h2>
+    <div class="readme">
+        <?php if (str_ends_with(strtolower($readme['name']), '.md')): ?>
+        <div class="markdown-body"><?= $parsedown->text($readme['content']) ?></div>
+        <?php else: ?>
+        <pre><?= htmlspecialchars($readme['content']) ?></pre>
+        <?php endif; ?>
+    </div>
+</section>
+<?php endif; ?>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/repo/tree.php b/app/views/repo/tree.php
new file mode 100644
index 0000000..41df03b
--- /dev/null
+++ b/app/views/repo/tree.php
@@ -0,0 +1,49 @@
+<?php $title = basename($repo['name'], '.git') . " - Tony's Git"; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+<?php require APP_ROOT . '/app/views/partials/repo_nav.php'; ?>
+
+<?php
+$path_parts = $path ? explode('/', $path) : [];
+$breadcrumb = [];
+?>
+
+<div class="breadcrumb">
+    <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?>"><?= htmlspecialchars(basename($repo['name'], '.git')) ?></a>
+    <?php
+    $accumulated = '';
+    foreach ($path_parts as $part):
+        $accumulated .= '/' . $part;
+    ?>
+    / <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?><?= htmlspecialchars($accumulated) ?>"><?= htmlspecialchars($part) ?></a>
+    <?php endforeach; ?>
+</div>
+
+<table class="tree">
+    <?php if ($path): ?>
+    <tr>
+        <td class="mode"></td>
+        <td class="name">
+            <?php
+            $parent = dirname($path);
+            $parent_url = $parent === '.' ? '' : '/' . $parent;
+            ?>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?><?= htmlspecialchars($parent_url) ?>">..</a>
+        </td>
+    </tr>
+    <?php endif; ?>
+    <?php foreach ($tree as $entry): ?>
+    <tr>
+        <td class="mode"><?= $entry['mode'] ?></td>
+        <td class="name">
+            <?php $entry_path = $path ? "$path/{$entry['name']}" : $entry['name']; ?>
+            <?php if ($entry['type'] === 'tree'): ?>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/tree/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($entry_path) ?>"><?= htmlspecialchars($entry['name']) ?>/</a>
+            <?php else: ?>
+            <a href="/<?= htmlspecialchars($repo['name']) ?>/blob/<?= htmlspecialchars($ref) ?>/<?= htmlspecialchars($entry_path) ?>"><?= htmlspecialchars($entry['name']) ?></a>
+            <?php endif; ?>
+        </td>
+    </tr>
+    <?php endforeach; ?>
+</table>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..dbf2fd5
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1770197578,
+        "narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..d359293
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,37 @@
+{
+  description = "git-btw - A minimal git web interface";
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+  };
+  outputs = {
+    self,
+    nixpkgs,
+  }: let
+    systems = ["x86_64-linux" "aarch64-linux"];
+
+    forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
+  in {
+    devShells = forAllSystems (pkgs: {
+      default = pkgs.mkShell {
+        packages = [
+          pkgs.php
+          pkgs.just
+        ];
+        shellHook = ''
+          export PS1="(git-btw) $PS1"
+          echo ""
+          echo "  git-btw dev server"
+          echo "  ------------------"
+          echo "  just dev         - start php server on localhost:8888"
+          echo "  just dev 3000    - start on custom port"
+          echo ""
+          echo "  GIT_ROOT defaults to ./test-repos"
+          echo "  Create test repos: just init-test-repos"
+          echo ""
+        '';
+      };
+    });
+
+    formatter = forAllSystems (pkgs: pkgs.alejandra);
+  };
+}
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..e3ce17d
--- /dev/null
+++ b/justfile
@@ -0,0 +1,15 @@
+default:
+    @just --list
+
+dev port="8888":
+    GIT_ROOT={{justfile_directory()}}/test-repos php -S localhost:{{port}} -t public
+
+init-test-repos:
+    mkdir -p test-repos
+    @if [ ! -d "test-repos/git-btw.git" ]; then \
+        git clone --bare . test-repos/git-btw.git; \
+        echo "Created test-repos/git-btw.git"; \
+    fi
+
+clean-test-repos:
+    rm -rf test-repos
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..ef373fe
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,465 @@
+:root {
+    --bg: #0d1117;
+    --bg-secondary: #161b22;
+    --bg-tertiary: #21262d;
+    --fg: #c9d1d9;
+    --fg-muted: #8b949e;
+    --fg-subtle: #6e7681;
+    --border: #30363d;
+    --link: #58a6ff;
+    --link-hover: #79c0ff;
+    --green: #3fb950;
+    --red: #f85149;
+    --yellow: #d29922;
+}
+
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+html {
+    font-size: 14px;
+}
+
+body {
+    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+    background: var(--bg);
+    color: var(--fg);
+    line-height: 1.5;
+    min-height: 100vh;
+}
+
+a {
+    color: var(--link);
+    text-decoration: none;
+}
+
+a:hover {
+    color: var(--link-hover);
+    text-decoration: underline;
+}
+
+header {
+    background: var(--bg-secondary);
+    border-bottom: 1px solid var(--border);
+    padding: 1rem 2rem;
+}
+
+header nav {
+    max-width: 1200px;
+    margin: 0 auto;
+}
+
+header .logo {
+    font-weight: bold;
+    font-size: 1.1rem;
+}
+
+main {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 2rem;
+}
+
+footer {
+    border-top: 1px solid var(--border);
+    padding: 1rem 2rem;
+    text-align: center;
+    color: var(--fg-subtle);
+    margin-top: 4rem;
+}
+
+h1, h2, h3 {
+    font-weight: 600;
+    margin-bottom: 1rem;
+}
+
+h1 { font-size: 1.5rem; }
+h2 { font-size: 1.25rem; color: var(--fg-muted); }
+h3 { font-size: 1.1rem; }
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 1rem 0;
+}
+
+th, td {
+    text-align: left;
+    padding: 0.5rem 1rem;
+    border-bottom: 1px solid var(--border);
+}
+
+th {
+    background: var(--bg-secondary);
+    color: var(--fg-muted);
+    font-weight: 600;
+}
+
+tr:hover {
+    background: var(--bg-secondary);
+}
+
+code, pre {
+    font-family: inherit;
+    background: var(--bg-secondary);
+    border-radius: 4px;
+}
+
+pre {
+    padding: 1rem;
+    overflow-x: auto;
+}
+
+code {
+    padding: 0.2rem 0.4rem;
+}
+
+.repo-header {
+    margin-bottom: 1.5rem;
+}
+
+.repo-header h1 {
+    margin-bottom: 0.25rem;
+}
+
+.repo-header .description {
+    color: var(--fg-muted);
+}
+
+.repo-nav {
+    display: flex;
+    gap: 1.5rem;
+    margin-bottom: 1rem;
+    padding-bottom: 1rem;
+    border-bottom: 1px solid var(--border);
+}
+
+.repo-nav a {
+    color: var(--fg-muted);
+}
+
+.repo-nav a:hover {
+    color: var(--fg);
+}
+
+.clone-urls {
+    display: flex;
+    gap: 1rem;
+    margin-bottom: 2rem;
+    flex-wrap: wrap;
+}
+
+.clone-urls code {
+    background: var(--bg-tertiary);
+    padding: 0.5rem 1rem;
+    font-size: 0.85rem;
+}
+
+.tree .mode {
+    width: 80px;
+    color: var(--fg-subtle);
+    font-size: 0.85rem;
+}
+
+.tree .name {
+    font-weight: 500;
+}
+
+.commits .hash {
+    width: 80px;
+}
+
+.commits .hash a {
+    font-family: inherit;
+}
+
+.commits .subject {
+    max-width: 500px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.commits .author {
+    color: var(--fg-muted);
+    width: 150px;
+}
+
+.commits .date {
+    color: var(--fg-subtle);
+    width: 120px;
+    text-align: right;
+}
+
+.summary-section {
+    margin-bottom: 3rem;
+}
+
+.summary-section h2 {
+    border-bottom: 1px solid var(--border);
+    padding-bottom: 0.5rem;
+}
+
+.breadcrumb {
+    margin-bottom: 1rem;
+    color: var(--fg-muted);
+}
+
+.blob-meta {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: var(--bg-secondary);
+    padding: 0.75rem 1rem;
+    border: 1px solid var(--border);
+    border-bottom: none;
+    border-radius: 4px 4px 0 0;
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+}
+
+.blob-content {
+    border: 1px solid var(--border);
+    border-radius: 0 0 4px 4px;
+    overflow-x: auto;
+}
+
+.blob-content table.code {
+    margin: 0;
+}
+
+.blob-content table.code td {
+    border: none;
+    padding: 0;
+    vertical-align: top;
+}
+
+.blob-content .line-num {
+    width: 1%;
+    min-width: 50px;
+    padding: 0 1rem;
+    text-align: right;
+    user-select: none;
+    background: var(--bg-secondary);
+    color: var(--fg-subtle);
+    border-right: 1px solid var(--border);
+}
+
+.blob-content .line-num a {
+    color: inherit;
+}
+
+.blob-content .line-num a:hover {
+    color: var(--link);
+    text-decoration: none;
+}
+
+.blob-content .line-code {
+    white-space: pre;
+}
+
+.blob-content .line-code pre {
+    margin: 0;
+    padding: 0 0 0 1rem;
+    background: none;
+    border-radius: 0;
+}
+
+.blob-content .line-code code {
+    padding: 0;
+    background: none;
+}
+
+.blob-content tr:hover {
+    background: var(--bg-tertiary);
+}
+
+.blob-content tr:target {
+    background: rgba(88, 166, 255, 0.15);
+}
+
+.binary-notice {
+    padding: 2rem;
+    text-align: center;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    color: var(--fg-muted);
+}
+
+.commit-detail {
+    margin-bottom: 2rem;
+}
+
+.commit-detail h2 {
+    color: var(--fg);
+    border: none;
+    padding: 0;
+}
+
+.commit-meta {
+    display: grid;
+    grid-template-columns: 80px 1fr;
+    gap: 0.5rem 1rem;
+    margin: 1rem 0;
+    color: var(--fg-muted);
+}
+
+.commit-meta dt {
+    font-weight: 600;
+}
+
+.commit-meta dd code {
+    font-size: 0.9rem;
+}
+
+.commit-body {
+    background: var(--bg-secondary);
+    padding: 1rem;
+    border-radius: 4px;
+    white-space: pre-wrap;
+    color: var(--fg-muted);
+}
+
+.diff-container {
+    margin-top: 2rem;
+}
+
+.diff {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    font-size: 0.9rem;
+    line-height: 1.4;
+}
+
+.pagination {
+    display: flex;
+    justify-content: space-between;
+    margin-top: 2rem;
+    padding-top: 1rem;
+    border-top: 1px solid var(--border);
+}
+
+.refs-section {
+    margin-bottom: 2rem;
+}
+
+.refs-list {
+    list-style: none;
+}
+
+.refs-list li {
+    padding: 0.5rem 0;
+    border-bottom: 1px solid var(--border);
+    display: flex;
+    gap: 1rem;
+    align-items: center;
+}
+
+.refs-list .ref-link {
+    color: var(--fg-subtle);
+    font-size: 0.85rem;
+}
+
+.readme {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 1rem;
+}
+
+.readme pre {
+    background: none;
+    padding: 0;
+    margin: 0;
+    white-space: pre-wrap;
+}
+
+.markdown-body {
+    line-height: 1.7;
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4 {
+    margin-top: 1.5em;
+    margin-bottom: 0.5em;
+    color: var(--fg);
+}
+
+.markdown-body h1 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
+.markdown-body h2 { font-size: 1.3rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
+.markdown-body h3 { font-size: 1.1rem; }
+
+.markdown-body p {
+    margin: 1em 0;
+}
+
+.markdown-body ul,
+.markdown-body ol {
+    margin: 1em 0;
+    padding-left: 2em;
+}
+
+.markdown-body li {
+    margin: 0.25em 0;
+}
+
+.markdown-body code {
+    background: var(--bg-tertiary);
+    padding: 0.2em 0.4em;
+    border-radius: 3px;
+    font-size: 0.9em;
+}
+
+.markdown-body pre {
+    background: var(--bg-tertiary);
+    padding: 1em;
+    border-radius: 4px;
+    overflow-x: auto;
+    margin: 1em 0;
+}
+
+.markdown-body pre code {
+    background: none;
+    padding: 0;
+}
+
+.markdown-body blockquote {
+    border-left: 3px solid var(--border);
+    margin: 1em 0;
+    padding-left: 1em;
+    color: var(--fg-muted);
+}
+
+.markdown-body table {
+    margin: 1em 0;
+}
+
+.markdown-body img {
+    max-width: 100%;
+}
+
+.markdown-body hr {
+    border: none;
+    border-top: 1px solid var(--border);
+    margin: 2em 0;
+}
+
+.error {
+    text-align: center;
+    padding: 4rem 2rem;
+}
+
+.error h1 {
+    color: var(--red);
+}
+
+.hljs {
+    background: transparent !important;
+}
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..da75d8b
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,177 @@
+<?php
+
+define('APP_ROOT', dirname(__DIR__));
+
+require APP_ROOT . '/app/models/Git_Model.php';
+require APP_ROOT . '/app/lib/Parsedown.php';
+
+$parsedown = new Parsedown();
+$parsedown->setSafeMode(true);
+
+$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$uri = rawurldecode($uri);
+$method = $_SERVER['REQUEST_METHOD'];
+
+$routes = [
+    'GET /' => 'home',
+    'GET /(?<repo>[^/]+\.git)' => 'repo_summary',
+    'GET /(?<repo>[^/]+\.git)/tree/(?<ref>[^/]+)(?<path>/.*)?$' => 'repo_tree',
+    'GET /(?<repo>[^/]+\.git)/blob/(?<ref>[^/]+)(?<path>/.+)$' => 'repo_blob',
+    'GET /(?<repo>[^/]+\.git)/raw/(?<ref>[^/]+)(?<path>/.+)$' => 'repo_raw',
+    'GET /(?<repo>[^/]+\.git)/log/(?<ref>[^/]+)?$' => 'repo_log',
+    'GET /(?<repo>[^/]+\.git)/commit/(?<hash>[a-f0-9]+)$' => 'repo_commit',
+    'GET /(?<repo>[^/]+\.git)/refs$' => 'repo_refs',
+];
+
+$handler = null;
+$params = [];
+
+foreach ($routes as $pattern => $h) {
+    [$route_method, $route_pattern] = explode(' ', $pattern, 2);
+    if ($method !== $route_method) continue;
+
+    $regex = '#^' . $route_pattern . '$#';
+    if (preg_match($regex, $uri, $matches)) {
+        $handler = $h;
+        $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
+        break;
+    }
+}
+
+if (!$handler) {
+    http_response_code(404);
+    $error = '404 Not Found';
+    require APP_ROOT . '/app/views/error.php';
+    exit;
+}
+
+$params = array_map(fn($v) => trim($v, '/'), $params);
+
+switch ($handler) {
+    case 'home':
+        $repos = Git_Model::list_repos();
+        require APP_ROOT . '/app/views/home.php';
+        break;
+
+    case 'repo_summary':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $ref = $repo['default_branch'];
+        $tree = Git_Model::get_tree($params['repo'], $ref);
+        $commits = Git_Model::get_commits($params['repo'], $ref, 10);
+        $readme = Git_Model::get_readme($params['repo'], $ref);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/summary.php';
+        break;
+
+    case 'repo_tree':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $ref = $params['ref'] ?: $repo['default_branch'];
+        $path = $params['path'] ?? '';
+        $tree = Git_Model::get_tree($params['repo'], $ref, $path);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/tree.php';
+        break;
+
+    case 'repo_blob':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $ref = $params['ref'];
+        $path = $params['path'];
+        $content = Git_Model::get_blob($params['repo'], $ref, $path);
+        if ($content === null) {
+            http_response_code(404);
+            $error = 'File not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $size = Git_Model::get_blob_size($params['repo'], $ref, $path);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/blob.php';
+        break;
+
+    case 'repo_raw':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            echo 'Repository not found';
+            exit;
+        }
+        $ref = $params['ref'];
+        $path = $params['path'];
+        $content = Git_Model::get_blob($params['repo'], $ref, $path);
+        if ($content === null) {
+            http_response_code(404);
+            echo 'File not found';
+            exit;
+        }
+        header('Content-Type: text/plain; charset=utf-8');
+        header('Content-Disposition: inline; filename="' . basename($path) . '"');
+        echo $content;
+        break;
+
+    case 'repo_log':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $ref = $params['ref'] ?: $repo['default_branch'];
+        $page = max(1, (int)($_GET['page'] ?? 1));
+        $per_page = 50;
+        $commits = Git_Model::get_commits($params['repo'], $ref, $per_page, ($page - 1) * $per_page);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/log.php';
+        break;
+
+    case 'repo_commit':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $commit = Git_Model::get_commit($params['repo'], $params['hash']);
+        if (!$commit) {
+            http_response_code(404);
+            $error = 'Commit not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $diff = Git_Model::get_diff($params['repo'], $params['hash']);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/commit.php';
+        break;
+
+    case 'repo_refs':
+        $repo = Git_Model::get_repo_info($params['repo']);
+        if (!$repo) {
+            http_response_code(404);
+            $error = 'Repository not found';
+            require APP_ROOT . '/app/views/error.php';
+            exit;
+        }
+        $refs = Git_Model::get_refs($params['repo']);
+        $clone_urls = Git_Model::get_clone_urls($params['repo']);
+        require APP_ROOT . '/app/views/repo/refs.php';
+        break;
+}