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

Я кодочтец! Если нужен код, он тут. Итоговое решение вылилось в пакет для Laravel.
Сидим значит с пацанами кодим... Работая над интересным проектом, от заказчика приходит задача «Ребята, надо для определенного вида статей генерировать оглавление налету». Прилетела эта задача в меня, в прошлом году. Как, чем это реализовывать было непонятно и неизвестно. Один известный поисковик результатов, которые бы мне подошли, по данной теме не выдавал. Надо было решение придумывать самому. И один известный портал с готовыми решениями тоже мне не помог.
Сел я, бахнул пару литров тёмного... Собрался я с мыслью что да как делать и принялся кодить. Сначала всё казалось просто: в тексте есть заголовки разного уровня (теги h1-h6), по ним и надо строить оглавление. Ну, думаю, сейчас буду парсить текст по шаблону и всё это дело в массив. Во время работы посетила меня муза и начался диалог:
— кодер, а что ты будешь делать, если заголовки не будут в строгом порядке?
— что? как это?
— представь, идёт h1, потом в тексте h2, h3, опять h2, h1, h2, h3, h4. Это строгий порядок, т.к. заголовки нижнего уровня идут строго за родителем. А теперь представь, что после h2 идёт по тексту не h3, а h4 или h6! И после h6 h4 и так далее, как эквалайзер.
Вот тут я и покрылся холодным потом. «Ну», — думаю, «этого не может быть». Решил уточнить у заказчика и таки да! Такое возможно!..
Нахреначился я в гов... Выпив чашку чая, пораскинув мозгами, я решил, что уже массивом не отделаешься, т.к. мне надо знать когда родитель открывать и закрывать. А массив либо он есть, либо его нет и определить куда именно мне надо сохранить очередной заголовок невозможно. Т.к. все эти дела происходят на API, на SPA соответственно каждый уровень заголовка должен быть на своём расстоянии от левого края. К тому же, надо хранить еще сам заголовок и ссылку на него. Ссылка формируется из самого заголовка и должна быть якорем, чтобы кликнул и попал в нужную часть текста.
Не тяжело, а интересно... Именно так я себе и подумал, а еще подумал, что открывать и закрывать родителя и дочерние элементы я могу сам, собирая данные в JSON.
Первая строка кода такая:
Она не сразу выбилась в лидеры, а появилась после того, как добавилось требование, что должна быть возможность вставлять заголовки h7-h10, которых в wysiwyg редакторе не было. Для этого была создана своя магия, в результате которой мы получили ситуацию, когда тег заголовка мог быть обрамлён абзацем. Вот данная строка и убирает этот самый абзац (тег p).
Дальше мы собираем в массив все заголовки, которые есть в тексте в массив $items
И дальше начинается карусель. Сначала мне надо открыть всё моё оглавление:
Затем по заголовкам запускается большой цикл, внутрь которого мы и будем нырять:
В первую очередь необходимо получить текст заголовка, очистив его от тегов и прочей шелухи. Функция replaceH1Symbols производит замену некоторых html-сущностей на специальные символы (например < превращается в «). В свойстве stripTags хранится вот такая регулярка
После того, как имя получено сформируем ссылку для него:
, где в свойстве symbols хранятся все символы-врединки (кавычки, скобки, апострофы, знаки препинания и т.п.), а в spaces всё, как может выглядеть пробел, ведь его в ссылке быть не должно.
Итак, ссылка есть. Дальше надо проверить, а есть ли такая ссылка уже в тексте. Ведь по тексту могут встречаться одинаковые заголовки. Если такая ссылка есть и, возможно она не одна, то нашей новой ссылке надо приписать её порядковый номер
Убейте меня! Начинаем формировать наше оглавление. Сначала проверяем начало ли у нас цикла или нет
Если начало, то всё хорошо, а если нет? Проверяем на то, является ли уровень текущего заголовка больше предыдущего (например, предыдущий h2, а текущий h4). Возможно, данная формулировка не совсем корректна, но больше\меньше я пишу, отталкиваясь от цифры возле h.
Если да, то нам необходимо посчитать какая разница между этими заголовками
и открыть дочернее подменю
Затем мы записываем какого уровня у нас наш предыдущий заголовок, т.к. если его порядок меньше (2<4), то он родитель нашего текущего заголовка.
И записываем общее количество внутренних элементов:
Затем запускаем цикл вложенности этих самых внутренних элементов, которых нет.
И после этого наконец-то вставляем наш заголовок
Как бы мне это развидеть? Дальше рассматриваем ситуацию наоборот, когда текущий заголовок меньше предыдущего. Например, h2 идёт после h4.
Затем мы высчитываем разницу между заголовками и при наличии subItemsCount нам надо закрыть все внутренние элементы, которые были открыты раннее. Спрашиваете, почему я умножаю на 2? Верьте, просто верьте. Это магия, у которой раньше было объяснение, но сейчас оно покрыто мифами о парности открывающихся\закрывающихся фигурных скобочек.
И вставляем наш текущий заголовок
Эй, парень, ты наркоман? Последняя проверка — равен ли уровень текущего заголовка предыдущему. Например, был h2 и опять h2. Здесь всё просто: закрыть предыдущий элемент и вставить текущий.
Обещать, не значит жениться... Обманул, не последняя. Последняя проверка на то, является ли текущий заголовок последним или нет. Ведь если да, нам надо закрыть все открытые до него уровни.
И не забыть имя положить в массив используемых имён:
Вот и сказочке конец Осталось только вне цикла закрыть наше оглавление
Всё. Оглавление готово к употреблению. Осталось только в тексте проставить якоря на самих заголовках.
Шта? Данный JSON на выходе из API декодируется и SPA получает объект.
Я читал, я молодец? Спасибо, что уделили внимание и надеюсь, что публикация окажется полезной. Любые советы, критику принимаю 24\7. Удачного всем кодинга и не только!

— кодер, а что ты будешь делать, если заголовки не будут в строгом порядке?
— что? как это?
— представь, идёт h1, потом в тексте h2, h3, опять h2, h1, h2, h3, h4. Это строгий порядок, т.к. заголовки нижнего уровня идут строго за родителем. А теперь представь, что после h2 идёт по тексту не h3, а h4 или h6! И после h6 h4 и так далее, как эквалайзер.
Вот тут я и покрылся холодным потом. «Ну», — думаю, «этого не может быть». Решил уточнить у заказчика и таки да! Такое возможно!..
Первая строка кода такая:
$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 . '"';
}
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 . '"';
}
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 .= "}";
Всё. Оглавление готово к употреблению. Осталось только в тексте проставить якоря на самих заголовках.