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']) ?> <<?= htmlspecialchars($commit['author_email']) ?>></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 ?>">← Newer</a>
+ <?php endif; ?>
+ <?php if (count($commits) === $per_page): ?>
+ <a href="?page=<?= $page + 1 ?>">Older →</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;
+}