Подсветка кода в markdown с помощью GeSHi

17 сент. 2018 г.

Всем привет! Сегодня расскажу про подсветку кода в markdown файлах. То, что описано в этой статье, Я использую непосредственно на этом сайте. Для отображения этой самой статьи!

Вероятно, проще всего сделать блог на wordpress. Но я же все-таки - программист!. Поэтому я предпочел сделать его на Symfony. Оба подхода имеют свои преимущества и недостатки. Так, например, мне удобнее писать статьи не в админке, а в IDE. И хранить все тексты не в базе данных, а в файлах под git.

Можно было бы просто под каждую статью сделать отдельный html файл. Но это не очень удобно, т.к. часть времени уходит на верстку и прописывание html тегов. Не сложно, но утомительно и рутинно.

Гораздо удобнее писать тексты в markdown. Также стоит учесть, что этот блог - про разработку и программирование. И поэтому важно, чтобы была удобная подсветка кода. Для наглядности я подготовил несложный пример. Итак, приступим!

Markdown

Посты будем хранить в папке posts в формате .md. Пост может содержать markdown синтаксис а также чистый html.

Отдельно стоит обратить внимание на блоки кода. Чтобы код красиво подсвечивался, нужно указать язык/технелогию. Например php или bash.

Пример поста в markdown формате: https://raw.githubusercontent.com/antonshell/markdown_geshi_example/master/posts/demo-markdown.md

Преобразование в html

Но, в конечном итоге, нам все равно понадобится html для отображения на сайте. Будем генерировать его автоматически с помощью парсера markdown. Я решил использовать парсер Parsedown.

Устанавливаем через composer:

composer require erusev/parsedown

Дальше парсим markdown и сохраняем в html файл. Функция replaceContentBlocks отвечает за подсветку кода. Реализовано в классе /src/PostBuilder.php.

/**
 * @param $file
 */
public function buildFromMarkdown($file)
{
    $content = file_get_contents(__DIR__ . '/../posts');
    $parsedown = new \Parsedown();

    $content = $parsedown->text($content);
    $content = $this->replaceContentBlocks($content);

    $file = str_replace('.md','.html', $file);
    file_put_contents(__DIR__ . '/../build/posts/' . $file, $content);
}

Подсветка кода

Для подсветки кода используем GeSHi Раньше импользовал этот сервис highlight.hohli. Который генерирует код с помощью этой библиотеки.

Устанавливаем через composer:

composer require geshi/geshi

Подсвечиваем блок кода:

$lang = 'php';
$code = '$content = $parsedown->text($content);
$content = $this->replaceContentBlocks($content);';

$geshi = new GeSHi($code, $lang);
$block = $geshi->parse_code();

Будет сгенерирован html такого вида:

<pre class="php" style="font-family:monospace;"><span style="color: #000088;">$content</span> <span style="color: #339933;">=</span> <span style="color: #000088;">$parsedown</span><span style="color: #339933;">-&gt;</span><span style="color: #004000;">text</span><span style="color: #009900;">&#40;</span><span style="color: #000088;">$content</span><span style="color: #009900;">&#41;</span><span style="color: #339933;">;</span>
<span style="color: #000088;">$content</span> <span style="color: #339933;">=</span> <span style="color: #000088;">$this</span><span style="color: #339933;">-&gt;</span><span style="color: #004000;">replaceContentBlocks</span><span style="color: #009900;">&#40;</span><span style="color: #000088;">$content</span><span style="color: #009900;">&#41;</span><span style="color: #339933;">;</span></pre>

Подсветка кода в markdown

Теперь нужно подсветить код непосредственно в markdown посте. Нужно найти все блоки кода, вырезать, и вставить на их место сгенерированный подсвеченый html. Реализовано в классе /src/PostBuilder.php.

Сначала определяем все позиции, где начинаются блоки кода. Ищем по ключевому слову <pre><code class="language. При обработке markdown блоки кода по-умолчанию оборачиваются в тег .

private $startBLockDelimiter = '<pre><code class="language';

/**
 * @param $content
 * @return array
 */
private function getCodeBlocksPositions($content){
   $lastPos = 0;
   $positions = [];

   while (($lastPos = strpos($content, $this->startBLockDelimiter, $lastPos))!== false) {
       $positions[] = $lastPos;
       $lastPos = $lastPos + strlen($this->startBLockDelimiter);
   }

   return $positions;
}

Дальше обходим все блоки, для кадого определяем последнюю позицию.

/**
 * highlight code blocks with geshi
 *
 * @param $content
 * @return mixed
 */
private function replaceContentBlocks($content){
    $newContent = $content;

    // get start positions of code blocks
    $positions = $this->getCodeBlocksPositions($content);

    // iterate code blocks
    foreach ($positions as $startPos) {
        // get end position, get code block form text
        $stripedContent = substr($content, $startPos);
        $endPos = strpos($stripedContent, $this->endBlockDelimiter);
        $replaceContent = substr($content, $startPos, $endPos + strlen($this->endBlockDelimiter));

        $block = substr($content, $startPos, $endPos);
        $startCodePos = strpos($stripedContent,'">') + 2;
        $block = substr($block, $startCodePos);
        $block = htmlspecialchars_decode($block);

        // detect code block language
        $lang = $this->getCodeBlockLanguage($replaceContent);

        // highlight code block with geshi
        $geshi = new GeSHi($block, $lang);
        $block = $geshi->parse_code();

        // replace source code with highlighted
        $newContent = str_replace($replaceContent, $block, $newContent);
    }

    return $newContent;
}

Определяем язык:

/**
 * detect code block language
 *
 * @param $replaceContent
 * @return bool|string
 */
private function getCodeBlockLanguage($replaceContent){
    $lang = self::DEFAULT_LANGUAGE;
    if(strpos($replaceContent, $this->startBlockDelimiter) === 0){
        $replaceContent = str_replace($this->startBlockDelimiter, '', $replaceContent);
        $endPos = strpos($replaceContent, '">');
        $lang = substr($replaceContent,0, $endPos);
        $lang = trim($lang,'-');
    }

    return $lang;
}

Получаем блок, передаем его geshi. Потом заменяем исхдный код, вставляем на его место подсвеченый код.

Генерация постов

Посты генерируются скриптом build_posts.php

<?php

use src\PostBuilder;

require '_bootstrap.php';

echo "Build posts ... \n";
$postBuilder = new PostBuilder();
$files = $postBuilder->getFiles();
foreach($files as $file) {
    if(in_array($file, ['.', '..'])){ continue; }
    echo "generate - $file ... \n";
    $ext = pathinfo($file, PATHINFO_EXTENSION);
    $postBuilder->buildFromMarkdown($file);
}

echo "Job is done \n";

Выполняем консольную команду. Нужно выполнять ее при каждом изменении постов.

php build_posts.php

Дальше отображаем на странице поста полученный .html файл. Вот и все. При необходимости можно поменять geshi на что-нибудь другое. Или добавить дополнительную обработку markdown.

Код примера доступен на github. На этом пока все. Спасибо за внимание!