Доброго времени суток! В данной публикации хочу рассказать и расскажу о том, как генерировать оглавление текста на PHP. Почему хаб «Laravel»? Данное решение вылилось в пакет, который можно просто подключить через composer.



Я кодочтец! Если нужен код, он тут. Итоговое решение вылилось в пакет для Laravel.

Сидим значит с пацанами кодим... Работая над интересным проектом, от заказчика приходит задача «Ребята, надо для определенного вида статей генерировать оглавление налету». Прилетела эта задача в меня, в прошлом году. Как, чем это реализовывать было непонятно и неизвестно. Один известный поисковик результатов, которые бы мне подошли, по данной теме не выдавал. Надо было решение придумывать самому. И один известный портал с готовыми решениями тоже мне не помог.

Сел я, бахнул пару литров тёмного... Собрался я с мыслью что да как делать и принялся кодить. Сначала всё казалось просто: в тексте есть заголовки разного уровня (теги h1-h6), по ним и надо строить оглавление. Ну, думаю, сейчас буду парсить текст по шаблону и всё это дело в массив. Во время работы посетила меня муза и начался диалог:
— кодер, а что ты будешь делать, если заголовки не будут в строгом порядке?
— что? как это?
— представь, идёт h1, потом в тексте h2, h3, опять h2, h1, h2, h3, h4. Это строгий порядок, т.к. заголовки нижнего уровня идут строго за родителем. А теперь представь, что после h2 идёт по тексту не h3, а h4 или h6! И после h6 h4 и так далее, как эквалайзер.
Вот тут я и покрылся холодным потом. «Ну», — думаю, «этого не может быть». Решил уточнить у заказчика и таки да! Такое возможно!..

Нахреначился я в гов... Выпив чашку чая, пораскинув мозгами, я решил, что уже массивом не отделаешься, т.к. мне надо знать когда родитель открывать и закрывать. А массив либо он есть, либо его нет и определить куда именно мне надо сохранить очередной заголовок невозможно. Т.к. все эти дела происходят на API, на SPA соответственно каждый уровень заголовка должен быть на своём расстоянии от левого края. К тому же, надо хранить еще сам заголовок и ссылку на него. Ссылка формируется из самого заголовка и должна быть якорем, чтобы кликнул и попал в нужную часть текста.

Не тяжело, а интересно... Именно так я себе и подумал, а еще подумал, что открывать и закрывать родителя и дочерние элементы я могу сам, собирая данные в JSON.

Первая строка кода такая:

$description = preg_replace("/<(p|[hH](10|[1-9]))>(<[hH](10|[1-9]).*?>(.*?)<\/[hH](10|[1-9])>)<\/(p|[hH](10|[1-9]))>/", "$3", $description);

Она не сразу выбилась в лидеры, а появилась после того, как добавилось требование, что должна быть возможность вставлять заголовки h7-h10, которых в wysiwyg редакторе не было. Для этого была создана своя магия, в результате которой мы получили ситуацию, когда тег заголовка мог быть обрамлён абзацем. Вот данная строка и убирает этот самый абзац (тег p).

Дальше мы собираем в массив все заголовки, которые есть в тексте в массив $items

preg_match_all("/<[hH](10|[1-9]).*?>(.*?)<\/[hH](10|[1-9])>/", $description, $items);


И дальше начинается карусель. Сначала мне надо открыть всё моё оглавление:

$menu = "{";

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

for ($i = 0; $i < count($items[0]); $i++) {...}

В первую очередь необходимо получить текст заголовка, очистив его от тегов и прочей шелухи. Функция replaceH1Symbols производит замену некоторых html-сущностей на специальные символы (например < превращается в «). В свойстве stripTags хранится вот такая регулярка

/<\/?[^>]+>|\&[a-z]+;|\'|"/ 

$name = preg_replace($this->stripTags, "", trim(html_entity_decode($this->replaceH1Symbols($items[2][$i]), ENT_QUOTES)));

После того, как имя получено сформируем ссылку для него:

$link = preg_replace($this->symbols, "", strtolower($name));
$link = preg_replace($this->spaces, "-", $link);

, где в свойстве symbols хранятся все символы-врединки (кавычки, скобки, апострофы, знаки препинания и т.п.), а в spaces всё, как может выглядеть пробел, ведь его в ссылке быть не должно.

Итак, ссылка есть. Дальше надо проверить, а есть ли такая ссылка уже в тексте. Ведь по тексту могут встречаться одинаковые заголовки. Если такая ссылка есть и, возможно она не одна, то нашей новой ссылке надо приписать её порядковый номер

$repeatCount = count(array_keys($usedItem, $name));
if ($repeatCount > 0) {
    $link .= "-" . ($repeatCount + 1);
}

Убейте меня! Начинаем формировать наше оглавление. Сначала проверяем начало ли у нас цикла или нет

if ($i == 0) {
    $menu .= '"' . $i . '": {';
    $menu .= '"title": "' . $name . '",';
    $menu .= '"link": "' . $link . '"';
}

Если начало, то всё хорошо, а если нет? Проверяем на то, является ли уровень текущего заголовка больше предыдущего (например, предыдущий h2, а текущий h4). Возможно, данная формулировка не совсем корректна, но больше\меньше я пишу, отталкиваясь от цифры возле h.

elseif ($i != 0 && $items[1][$i] > $items[1][$i - 1]) {

Если да, то нам необходимо посчитать какая разница между этими заголовками

$quantity = $items[1][$i] - $items[1][$i - 1];

и открыть дочернее подменю

$menu .= ', "subItems": {';

Затем мы записываем какого уровня у нас наш предыдущий заголовок, т.к. если его порядок меньше (2<4), то он родитель нашего текущего заголовка.

array_push($parentItem, (int)$items[1][$i - 1]);

И записываем общее количество внутренних элементов:

$subItemsCount += $quantity;

Затем запускаем цикл вложенности этих самых внутренних элементов, которых нет.

for ($j = 1; $j <= $quantity - 1; $j++) {
    $menu .= "\"" . $j . "\":{";
    $menu .= '"subItems": {';
    array_push($parentItem, $items[1][$i - 1] + $j);
}

И после этого наконец-то вставляем наш заголовок

    $menu .= '"' . $i . '": {';
    $menu .= '"title": "' . $name . '",';
    $menu .= '"link": "' . $link . '"';
}

Как бы мне это развидеть? Дальше рассматриваем ситуацию наоборот, когда текущий заголовок меньше предыдущего. Например, h2 идёт после h4.

elseif ($i != 0 && $items[1][$i] < $items[1][$i - 1]) {

Затем мы высчитываем разницу между заголовками и при наличии subItemsCount нам надо закрыть все внутренние элементы, которые были открыты раннее. Спрашиваете, почему я умножаю на 2? Верьте, просто верьте. Это магия, у которой раньше было объяснение, но сейчас оно покрыто мифами о парности открывающихся\закрывающихся фигурных скобочек.

$quantity = $items[1][$i - 1] - $items[1][$i];
$menu .= "}";
if ($subItemsCount) {
    for ($j = 1; $j <= $quantity * 2; $j++) {
        $menu .= "}";
        if ($j % 2 == 0) {
            $subItemsCount--;
            array_pop($parentItem);
        }
    }
}

И вставляем наш текущий заголовок

    $menu .= ', "' . $i . '": {';
    $menu .= '"title": "' . $name . '",';
    $menu .= '"link": "' . $link . '"';
}

Эй, парень, ты наркоман? Последняя проверка — равен ли уровень текущего заголовка предыдущему. Например, был h2 и опять h2. Здесь всё просто: закрыть предыдущий элемент и вставить текущий.

else {
    $menu .= '}, "' . $i . '": {';
    $menu .= '"title": "' . $name . '",';
    $menu .= '"link": "' . $link . '"';
}

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

if (!array_key_exists($i + 1, $items[1])) {
    $a = $items[1][$i];

    $lastParent = array_shift($parentItem);

    if ($lastParent && $lastParent < $a) {
        for ($q = 0; $q <= ($a - $lastParent) * 2; $q++) {
            $menu .= "}";
        }
    } else {
        $menu .= "}";
    }
}

И не забыть имя положить в массив используемых имён:

$usedItem[] = $name;

Вот и сказочке конец Осталось только вне цикла закрыть наше оглавление

$menu .= "}";

Всё. Оглавление готово к употреблению. Осталось только в тексте проставить якоря на самих заголовках.

Шта? Данный JSON на выходе из API декодируется и SPA получает объект.

Я читал, я молодец? Спасибо, что уделили внимание и надеюсь, что публикация окажется полезной. Любые советы, критику принимаю 24\7. Удачного всем кодинга и не только!
Поделиться с друзьями
-->

Комментарии (13)


  1. DjPhoeniX
    30.03.2017 05:41
    +3

    Шта? JSON конкатенацией? А стек объектов (самое банальное что приходит в голову) мы ниасилили?


  1. redline_mc
    30.03.2017 05:50

    А зачем сбор JSON вручную? Чем не угодила функция json_encode?


    1. alutskevich
      30.03.2017 05:53

      json_encode
      преобразует данные в JSON, а если бы была возможность не собирать строку, то обошелся бы вообще без JSON. Почему решил собирать вручную написано в самой публикации ;)


      1. Ashterix
        02.04.2017 23:05

        Там написано, что вы просто решили собирать в JSON, думаю, что redline_mc имел ввиду, что все данные можно было собирать в ассоциативный массив, и в конце воспользоваться функцией json_encode.


        1. redline_mc
          04.04.2017 11:02

          Именно. Наполнили массив и единовременно перевели его в JSON.

          Та версия логики, что есть сейчас получается крайне неподдерживаемой — огромная вероятность потерять скобку или двоеточия при хоть каком-то расширении функционала + ни продебажить получающийся результат, ни повлиять на него после конкатенации.


  1. webmasterx
    30.03.2017 06:40

    Функция replaceH1Symbols производит замену некоторых html-сущностей на специальные символы

    а htmlentities чем вам не угодил?


    1. alutskevich
      30.03.2017 07:07

      html_entity_decode Вы имели в виду? Мне важно было, например, < заменять на «, а функция заменяет на <.


  1. JetMaster
    30.03.2017 07:42
    +2

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

    Смею предположить, это связано с тем, что вы искали какое-то мифическое «содержание текста», когда вам нужно было искать «Оглавление текста» (table of contents, TOC). У гугла на первой же странице

    http://stackoverflow.com/questions/4912275/automatically-generate-nested-table-of-contents-based-on-heading-tags
    https://github.com/caseyamcl/toc

    И да, никогда не используйте регулярки для парсинга html, тем более если не вы лично будете этот html набирать.


    1. alutskevich
      30.03.2017 09:23

      Спасибо за советы и поправку. В статье заменил «содержание» на «оглавление».


  1. cry_san
    30.03.2017 10:13

    В статье не хватает результата в виде примера.


  1. ellrion
    30.03.2017 11:09
    +1

    Без тестов пакет не пакет. И Лару вы прям за уши притянули.


  1. hlogeon
    30.03.2017 14:06

    Я вот тоже не понял захода с Laravel. Почему не сделать framework-agnostic пакет?


    1. alutskevich
      30.03.2017 14:41

      В будущем планирую сделать. По поводу Laravel: у меня это первый опыт разработки пакета и, в начале, я хотел реализовать еще конфигурационный файл с блек-джеком и плюшками, но в последний момент передумал. Если коротко, то ответ — because I can ;)