IT Notes

Hello, world!

Введение

Вероятно, в настоящее время разработка движка блога на чистом PHP с нуля не является самым разумным выбором, но я хотел закрепить свои навыки и знания веб-программирования, поэтому решил сделать это. Одним из побочных результатов стал несложный язык разметки текста, который я использую при написании заметок на этом сайте. Парсер этого языка вышел расширяемым, а сам синтаксис оказался удобным в использовании. Посмотрим на его реализацию поподробнее.

Синтаксис

В первую очередь я составил список тех элементов, без которых никак не обойтись:

  1. Заголовки;
  2. Изображения;
  3. Списки;
  4. Код;
  5. Ссылки.

Поскольку все эти элементы имеют свои теги в HTML, то чтобы не усложнять задачу, построим наш синтаксис также на базе тегов. Определим общий вид элемента синтаксиса:

<тег>@ [параметры] @<тег>.

Например, если нам нужно определить заголовок, то можно записать: h2@ Текст заголовка @h2, что будет преобразовано парсером при отображении заметки в <h2>Текст заголовка</h2>.

Но почему именно символ @? На самом деле, выбор достаточно произвольный. Он редко встречается в обычном тексте, поэтому я на нем и остановился.

А если нужно записать символ @ в тексте? Да и как я до этого записывал примеры разметки без их преобразования в теги HTML? Все просто. Достаточно считать тег h2@ действительным лишь тогда, когда после h2 идет ровно один символ @. Если же я записываю две "собаки" подряд, то парсер пропускает такой тег, а в конце делает замену @@ --> @ (в исходном тексте заметки мне пришлось писать 4 "собаки" подряд, чтобы вывести 2).

С общей идеей разобрались, а теперь попробуем определить теги для каждого элемента из приведенного выше списка:

  1. h2@ Заголовок @h2. Этот тег мы уже рассмотрели;
  2. img@ src, (=|-|+) @img. С изображениями немного сложнее. Мне бы хотелось иметь возможность выравнивать изображения по краям или по центру. Поэтому тег поддерживает два параметра: src - url изображения и одно из значений после запятой: =, - и + для выравнивания по центру, левому или правому краям соответственно;
  3. (ul|ol)@ Элементы списка @(ul|ol). Списки представлены блочным тегом. Элементы списка определяются символом перехода на новую строку. То есть каждая строка будет обернута HTML-тегом <li>;
  4. c@ inline-код @c и code@ block-код @code. Первый тег предназначен для отображения коротких фрагментов кода внутри параграфов и будет преобразован в <code>, а второй используется для вынесения более длинных блоков кода в отдельный абзац и оборачивается с помощью HTML-тега <pre>;
  5. a@ href, text @a. По аналогии с изображениями для ссылок используется два параметра: href - адрес ссылки и text - текст, который будет отображаться.

А теперь немного кода

Осталось написать функцию, которая бы на основе этой разметки выдавала корректный HTML-код. Практически ничего, кроме PHP-функции preg_replace_callback() нам для этого не понадобится. Главный цикл обработки тегов можно записать следующим образом:

foreach( $tags as $t ) {
    $res = preg_replace_callback(
        '/' . $t[ 'tag' ] . '@([^@].+[^@])@' . $t[ 'tag' ] . '/suU',
        $t[ 'func' ],
        $res
    );
}

Каждый тег, который нам нужен, помещен в массив $tags, по которому проходит цикл. При этом сами теги определяются именем 'tag' и функцией обратного вызова 'func'.

Индивидуалка ОЛЕНЬКА (33 лет) т.8 985 213-80-65 Москва, метро Фрунзенская.

Как видно, функции preg_replace_callback() мы передаем три аргумента:

  1. Строку регулярного выражения;
  2. Функцию обратного вызова;
  3. И текст заметки, которую хотим обработать.

Рассмотрим пример реализации обработчика одного из тегов, чтобы проанализировать выполняемые действия. Вот так можно определить элемент H2:

function parseH2( $m ) {
    return '<h2>' . mb_trim( $m[ 1 ] ) . '</h2>';
}

const TAG_H2 = [ 'tag' => 'h2', 'func' => 'parseH2' ];

Здесь все достаточно просто. После подстановки 'tag' в приведенное выше регулярное выражение получаем:

'/h2@(^[@].*[^@])@h2/suU'.

Обратим внимание на ключи поиска в конце регулярного выражения:

  1. s - заставляет рассматривать весь обрабатываемый текст в виде одной строки. То есть символ точка ( . ) в регулярном выражении начинает включать и символ переноса на новую строку;
  2. u - включает режим совместимости с символами юникода;
  3. U - "ungreedy"-режим. Поиск сокращается до минимального соответствия шаблону, чтобы предотвратить объединение двух и более одинаковых тегов в один.

Таким образом, каждый набор символов в тексте, который находится между h2@ и @h2, а также начинается и заканчивается любым символом, отличным от @, будет передан в функцию обратного вызова parseH2().

Функция parseH2() достаточно проста. В качестве входного аргумента preg_replace_callback() передает ей массив из двух элементов:

  1. $m[ 0 ] - содержит весь найденный текст, включая теги h2@ и @h2;
  2. $m[ 1 ] - содержит лишь то, что в регулярном выражении было обернуто в скобки, то есть текст нашего заголовка, который мы и хотим обернуть в HTML-тег <h2>.

Здесь стоит отметить, что на момент написания заметки в PHP еще не было функции mb_trim(). Она нам нужна, чтобы обрезать пробельные символы в начале и конце строки. Можно было бы воспользоваться стандартной функцией trim(), но она не работает с символами юникода. Код mb_trim() честно взят из комментариев с официальной страницы документации PHP и имеет следующий вид:

function mb_trim( $str, $trimChars = '\s' ) {
    return preg_replace( '/^[' . $trimChars . ']*(?U)(.*)[' . $trimChars . ']*$/su', '\1', $str );
}

Теперь мы можем размечать заголовки. Попробуем добавить поддержку списков:

function parseListItems( $str ) {
    return preg_replace_callback(
        '/^(.*)$/um',
        function( $m ) {
            $inLI = mb_trim( $m[ 1 ] );
            if( empty( $inLI ) ) return '';
            return '<li>' . $inLI . '</li>';
        },
        mb_trim( $str )
    );
}

function parseOL_UL( $m ) {
    $tag = $m[ 1 ];
    return '<' . $tag . '>' . parseListItems( $m[ 2 ] ) . '</' . $tag . '>';
}

const TAG_UL_OL = [ 'tag' => '(ul|ol)', 'func' => 'parseOL_UL' ];

И вновь все достаточно просто. Всего несколько отличий от H2. Поскольку нет смысла делать разные обработчики для тегов UL и OL, то мы определили параметр 'tag' => '(ul|ol'), то есть ul или ol. Причем, в функции-обработчике parseOL_UL() мы узнаем о том, какой же тег был выбран через параметр $m[ 1 ]. А элементы списка, которые находятся в $m[ 2 ], построчно обернем в HTML-тег <li> с помощью функции parseListItems(). Принцип ее работы также построен на вызове preg_replace_callback(). Но есть и несколько отличий:

  1. Вместо флага s в регулярном выражении задействован флаг m, который позволяет рассматривать каждую строку отдельно, выделяя начало символом ^ и конец символом $. Кроме того, флаг U в этом случае нам не нужен вовсе, поскольку мы работаем со всей строкой целиком;
  2. Добавлена проверка строки на пустоту, чтобы в списке не появлялись незаполненные пункты.

Есть свои особенности и у реализации тегов A и IMG. Рассмотрим, например, первую из них:

function parseA( $m ) {
    return preg_replace_callback(
        '/([^,]+)\s*,\s*(.+)/um',
        function( $m ) {
            $href = mb_trim( $m[ 1 ] );
            $text = mb_trim( $m[ 2 ] );
            return '<a href="' . $href . '">' . $text . '</a>';
        },
        mb_trim( $m[ 1 ] )
    );
}

const TAG_A = [ 'tag' => 'a', 'func' => 'parseA' ];

Как и при обработке элементов списков мы воспользовались еще одним вызовом preg_replace_callback(). Регулярное выражение построено таким образом, что сначала стоит href ссылки, то есть набор символов до запятой, а после запятой следует текст, который будет отображаться на странице. После чего все это преобразуется в HTML и возвращается для вывода.

Остальные теги строятся по тем же принципам, поэтому на них мы останавливаться не будем. Но как же абзацы? Сейчас все, что не будет обернуто в наши теги, отобразится браузером в виде сплошного текста без переносов строк, что не так уж смертельно, но, очевидно, не оптимально. Поэтому весь оставшийся текст, который находится между блочных HTML-тегов, обернем с помощью <p> по аналогии с тем, как мы делали это для элементов списков:

function parsePlainText( $str ) {
    return preg_replace_callback(
        '/^(.*)$/um',
        function( $m ) {
            $inP = mb_trim( $m[ 1 ] );
            if( empty( $inP ) ) return '';
            return '<p>' . $inP . '</p>';
        },
        mb_trim( $str )
    );
}

define( 'BLOCK_TAGS', 'h2|ul|ol|pre' );

$res = preg_replace_callback(
    '#(^|</(' . BLOCK_TAGS .').*>)(.*)($|<(' . BLOCK_TAGS . ')>)#suU',
    function( $m ) {
        return $m[ 1 ] . parsePlainText( $m[ 3 ] ) . $m[ 4 ];
    },
    mb_trim( $res )
);

Представленный выше код выделяет каждый фрагмент текста, который находится между блочными HTML-тегами, определенными с помощью define, или началом/концом содержимого заметки. А потом оборачивает каждую непустую строку получившихся фрагментов с помощью <p>, как мы это делали с <li>. В результате все, что осталось, будет разбито на абзацы с учетом всех переходов на новые строки.

Таким образом, код преобразования содержимого заметки принимает вид:

function parseH2( $m ) {
    return '<h2>' . mb_trim( $m[ 1 ] ) . '</h2>';
}
const TAG_H2 = [ 'tag' => 'h2', 'func' => 'parseH2' ];

function parseListItems( $str ) {
    return preg_replace_callback(
        '/^(.*)$/um',
        function( $m ) {
            $inLI = mb_trim( $m[ 1 ] );
            if( empty( $inLI ) ) return '';
            return '<li>' . $inLI . '</li>';
        },
        mb_trim( $str )
    );
}

function parseOL_UL( $m ) {
    $tag = $m[ 1 ];
    return '<' . $tag .'>' . parseListItems( $m[ 2 ] ) . '</' . $tag . '>';
}
const TAG_UL_OL = [ 'tag' => '(ul|ol)', 'func' => 'parseOL_UL' ];

function parseCode( $m ) {
    return '<pre><code>' . mb_trim( $m[ 1 ] ) . '</code></pre>';
}
const TAG_CODE = [ 'tag' => 'code', 'func' => 'parseCode' ];

function parseC( $m ) {
    return '<code>' . mb_trim( $m[ 1 ] ) . '</code>';
}
const TAG_C = [ 'tag' => 'c', 'func' => 'parseC' ];

function parseA( $m ) {
    return preg_replace_callback(
        '/([^,]+)\s*,\s*(.+)/um',
        function( $m ) {
            $href = mb_trim( $m[ 1 ] );
            $text = mb_trim( $m[ 2 ] );
            return '<a href="' . $href . '">' . $text . '</a>';
        },
        mb_trim( $m[ 1 ] )
    );
}
const TAG_A = [ 'tag' => 'a', 'func' => 'parseA' ];

function parseIMG( $m ) {
    return preg_replace_callback(
        '/([^,]+)\s*,\s*([^\s]+)/us',
        function( $m ) {
            $src = mb_trim( $m[ 1 ] );
            $align = mb_trim( $m[ 2 ] );
            $class = 'center_img';
            switch( $align ) {
            case '-':
                $class = 'left_img';
                break;
            case '+':
                $class = 'right_img';
                break;
            }
            return '<img src="' . $src . '" class="' . $class . '">';
        },
        mb_trim( $m[ 1 ] )
    );
}
const TAG_IMG = [ 'tag' => 'img', 'func' => 'parseIMG' ];

function parsePlainText( $str ) {
    return preg_replace_callback(
        '/^(.*)$/um',
        function( $m ) {
            $inP = mb_trim( $m[ 1 ] );
            if( empty( $inP ) ) return '';
            return '<p>' . $inP . '</p>';
        },
        mb_trim( $str )
    );
}

define( 'BLOCK_TAGS', 'h2|ul|ol|pre' );

function parseContent( $str, $tags = [ TAG_H2, TAG_UL_OL, TAG_CODE, TAG_C, TAG_A, TAG_IMG ] ) {
    $res = $str;
    foreach( $tags as $t ) {
        $res = preg_replace_callback(
            '/' . $t[ 'tag' ] . '@([^@].+[^@])@' . $t[ 'tag' ] . '/suU',
            $t[ 'func' ],
            $res
        );
    }

    $res = preg_replace_callback(
        '#(^|</(' . BLOCK_TAGS .').*>)(.*)($|<(' . BLOCK_TAGS . ')>)#suU',
        function( $m ) {
            return $m[ 1 ] . parsePlainText( $m[ 3 ] ) . $m[ 4 ];
        },
        mb_trim( $res )
    );

    $res = preg_replace( '/@@/u', '@', $res );

    return $res;
}

Здесь уже нет ничего принципиально нового, но обратим внимание на следующую строку:

$res = preg_replace( '/@@/u', '@', $res ).

Как и планировалось. Каждая пара стоящих рядом символов @@ превращается в @.

Еще есть смысл обратить внимание на входной параметр $tags главной функции parseContent(). У него уже есть значение по умолчанию, которое включает все подготовленные нами теги. Но при желании можно убрать или добавить нужные обработчики, чтобы ограничить или расширить возможности разметки текста. В первом же параметре ожидается передача текста для обработки.

Заключение

Таким образом, мы получили простой и расширяемый синтаксис для форматирования текста. И рассмотрели возможную реализацию его парсера средствами PHP на основе регулярных выражений и функции preg_replace_callback().

А при чем здесь тогда Hello, world? Исправляю эту оплошность:

#include <iostream>

int main( int argc, char** argv ) {
    std::cout << "Hello, world!" << std::endl;

    return 0;
}

Похожие публикации