Вероятно, в настоящее время разработка движка блога на чистом PHP с нуля не является самым разумным выбором, но я хотел закрепить свои навыки и знания веб-программирования, поэтому решил сделать это. Одним из побочных результатов стал несложный язык разметки текста, который я использую при написании заметок на этом сайте. Парсер этого языка вышел расширяемым, а сам синтаксис оказался удобным в использовании. Посмотрим на его реализацию поподробнее.
В первую очередь я составил список тех элементов, без которых никак не обойтись:
Поскольку все эти элементы имеют свои теги в HTML, то чтобы не усложнять задачу, построим наш синтаксис также на базе тегов. Определим общий вид элемента синтаксиса:
<тег>@ [параметры] @<тег>
.
Например, если нам нужно определить заголовок, то можно записать: h2@ Текст заголовка @h2
, что будет преобразовано парсером при отображении заметки в <h2>Текст заголовка</h2>
.
Но почему именно символ @
? На самом деле, выбор достаточно произвольный. Он редко встречается в обычном тексте, поэтому я на нем и остановился.
А если нужно записать символ @
в тексте? Да и как я до этого записывал примеры разметки без их преобразования в теги HTML? Все просто. Достаточно считать тег h2@
действительным лишь тогда, когда после h2
идет ровно один символ @
. Если же я записываю две "собаки" подряд, то парсер пропускает такой тег, а в конце делает замену @@ --> @
(в исходном тексте заметки мне пришлось писать 4 "собаки" подряд, чтобы вывести 2).
С общей идеей разобрались, а теперь попробуем определить теги для каждого элемента из приведенного выше списка:
h2@ Заголовок @h2
. Этот тег мы уже рассмотрели;img@ src, (=|-|+) @img
. С изображениями немного сложнее. Мне бы хотелось иметь возможность выравнивать изображения по краям или по центру. Поэтому тег поддерживает два параметра: src
- url изображения и одно из значений после запятой: =
, -
и +
для выравнивания по центру, левому или правому краям соответственно;(ul|ol)@ Элементы списка @(ul|ol)
. Списки представлены блочным тегом. Элементы списка определяются символом перехода на новую строку. То есть каждая строка будет обернута HTML-тегом <li>
;c@ inline-код @c
и code@ block-код @code
. Первый тег предназначен для отображения коротких фрагментов кода внутри параграфов и будет преобразован в <code>
, а второй используется для вынесения более длинных блоков кода в отдельный абзац и оборачивается с помощью HTML-тега <pre>
;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'
.
Как видно, функции preg_replace_callback()
мы передаем три аргумента:
Рассмотрим пример реализации обработчика одного из тегов, чтобы проанализировать выполняемые действия. Вот так можно определить элемент H2
:
function parseH2( $m ) {
return '<h2>' . mb_trim( $m[ 1 ] ) . '</h2>';
}
const TAG_H2 = [ 'tag' => 'h2', 'func' => 'parseH2' ];
Здесь все достаточно просто. После подстановки 'tag'
в приведенное выше регулярное выражение получаем:
'/h2@(^[@].*[^@])@h2/suU'
.
Обратим внимание на ключи поиска в конце регулярного выражения:
s
- заставляет рассматривать весь обрабатываемый текст в виде одной строки. То есть символ точка ( . )
в регулярном выражении начинает включать и символ переноса на новую строку;u
- включает режим совместимости с символами юникода;U
- "ungreedy"-режим. Поиск сокращается до минимального соответствия шаблону, чтобы предотвратить объединение двух и более одинаковых тегов в один.Таким образом, каждый набор символов в тексте, который находится между h2@
и @h2
, а также начинается и заканчивается любым символом, отличным от @
, будет передан в функцию обратного вызова parseH2()
.
Функция parseH2()
достаточно проста. В качестве входного аргумента preg_replace_callback()
передает ей массив из двух элементов:
$m[ 0 ]
- содержит весь найденный текст, включая теги h2@
и @h2
;$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()
. Но есть и несколько отличий:
s
в регулярном выражении задействован флаг m
, который позволяет рассматривать каждую строку отдельно, выделяя начало символом ^
и конец символом $
. Кроме того, флаг U
в этом случае нам не нужен вовсе, поскольку мы работаем со всей строкой целиком;Есть свои особенности и у реализации тегов 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;
}