Автоматическая генерация оглавления страницы

На днях появился вопрос об автоматическом создании оглавления статьи, а позже где-то проскочил комментарий, мол неплохо бы такое добавить на docs.modx.pro — там же все документы хорошо структурированы.

И действительно, а почему бы не добавить? Выделил полчасика и набросал сниппет makeContents, который генерирует вот такое оглавление:

Алгоритм работы простой: сниппет выбирает все заголовки от h1 до h5 и генерирует из них вложенный список, а после этого добавляет якоря перед заголовками в тексте.
<?php
// Если в тексте нет заголовков от h1 до h5 - выходим
if (!preg_match_all('#<h([1-5])>(.*?)</h[1-5]>#', $input, $headers)) {return;}
// Если заголовков меньше 2х - тоже выходим
if (count($headers[0]) < 2) {return;}

$base = $modx->makeUrl($modx->resource->id, '', '', 'full');
$from = $to = array();
$depth = 0;
$start = null;
// Генерация меню
$contents = '<ul id="page-contents">';
foreach ($headers[2] as $i => $header) {
	$header = preg_replace('#\s+#', ' ', trim(rtrim($header, ':!.?;')));
	$anchor = str_replace(' ', '-', $header);
	$header = "<a href=\"{$base}#{$anchor}\">{$header}</a>";
	
	if ($depth > 0) {
		if ($headers[1][$i] > $depth) {
			while ($headers[1][$i] > $depth) {
				$contents .= '<ul>';
				$depth ++;
			}
		}
		elseif ($headers[1][$i] < $depth) {
			while ($headers[1][$i] < $depth) {
				$contents .= '</ul>';
				$depth --;
			}
		}
	}
	$depth = $headers[1][$i];
	if ($start === null) {
		$start = $depth;
	}
	$contents .= '<li>' . $header . '</li>';
	
	$from[$i] = $headers[0][$i];
	$to[$i] = '<a name="' . $anchor . '" class="page-contents-link"></a>' . $headers[0][$i];
}
// Закрытие всех открытых списков
for ($i = 0; $i <= ($depth - $start); $i ++) {
	$contents .= "</ul>";
}
// Добавление якорей к заголовкам
$input = str_replace($from, $to, $input);

return $contents . $input;

Заморачиваться с чанками я не стал, потому что структура списка с идентификатором #page-contents позволяет оформить его на CSS как угодно.

У самих якорей указан класс .page-contents-link, чтобы можно было указать отступ от верхнего края:
a.page-contents-link {display: block; position: relative; top: -50px; visibility: hidden;}
Очень полезно при фиксированной шапке, как на docs.modx.pro.

Вызывать этот сниппет нужно фильтром вывода, например вот так:
[[Markdown:makeContents@Docs]]
Или вот так:
[[*content:makeContents]]

В результате, теперь мы можем давать более точные ссылки на разделы в документации, например: docs.modx.pro/komponentyi/pdotools/obshhie-parametryi#Способы-вызова-чанков

Сниппет поставляется «как есть». Автор не даёт никаких гарантий его работоспособности у вас на сайте и не несёт ответственности за любые возможные нарушения в его работе.
Василий Наумкин
09 декабря 2014, 08:57
modx.pro
39
4 496
+3

Комментарии: 22

Wassi Wassinen
09 декабря 2014, 17:33
0
" Все работает!
    Wassi Wassinen
    09 декабря 2014, 18:00
    0
    Что было бы здорово допилить — если текст редактировался кем-то в визуальном редакторе, то часто заголовки обернуты еще в массу тегов (по центру, жирный, бельше-меньше и т.д.), и такие заголовки не попадают в оглавление.
      Василий Наумкин
      09 декабря 2014, 19:55
      +2
      часто заголовки обернуты еще в массу тегов
      Заголовки обернуты в теги? Руки оторвать нужно такому редактору, простигосподи.

      htmlbook.ru/html/h1
      Теги h1...h6 относятся к блочным элементам, они всегда начинаются с новой строки, а после них другие элементы отображаются на следующей строке. Кроме того, перед заголовком и после него добавляется пустое пространство.
        Wassi Wassinen
        10 декабря 2014, 14:50
        0
        Был бы редактор — можно и оторвать. А так — менеджеры, с отметкой в резюме «уверенный пользователь ПК». :)

        И их можно простить — сайт для интранет. :)
      Павел Левин
      23 декабря 2014, 22:56
      0
      Внесу предложение.

      $base = $modx->makeUrl($modx->resource->id, '', '', 'abs');

      Относительные ссылки лучше… как мне кажется =)
        Василий Наумкин
        29 декабря 2014, 20:31
        0
        Тут недавно было сразу несколько однотипных вопросов из-за незнания о base href, так что я уже перестраховываюсь.

        Кто знает, что ему лучше — поправит и сам.
          Павел Левин
          29 декабря 2014, 22:13
          0
          Я просто отталкиваюсь от того, что если делать js скрипт подсветки внешних ссылок (как в википедии), то они будут иметь http в начале href… а так без разницы =)

          Собственно зачем далеко бежать)? Вот решение.

          — С наступающим :-)
        Денис Богдановский
        13 апреля 2015, 16:46
        0
        Думал как сделать навигацию по странице с фиксированным справа сайдбаром как тут. Написанный Василий сниппет оказался как нельзя кстати, но у меня возникли сложности, т.к. не знаю php :)

        Подскажите, а как можно изменить спиппет что бы содержимое страницы с раскиданными якорями можно было обернуть в один div, а сам список
        <ul id="page-contents"><ul>
        во второй div, не вложенный в первый?
          Василий Наумкин
          13 апреля 2015, 19:34
          1
          +1
          Замени в конце
          return $contents . $input;
          на
          $modx->setPlaceholder('page.contents', $contents);
          $modx->setPlaceholder('page.text', $input);

          И используй новые плейсхолдеры где хочешь:
          [[!+page.contents]] и [[!+page.text]]
            Денис Богдановский
            14 апреля 2015, 00:36
            0
            Все работает! Спасибо!!!
              Денис Богдановский
              14 апреля 2015, 01:12
              0
              Что то я рано обрадовался )

              Если вызвать на странице:
              [[*content:makeContents]]
              [[!+page.text]] [[!+page.contents]]


              … у меня получается, что на странице два плейсхолдера, один из которых дублирует содержимое. Если убрать [[!+page.contents]], вот такой вариант:

              [[*content:makeContents]]
              [[!+page.contents]]


              … выстроит список, но якоря не раскидывает.

              Василий, а как только список в плейсходрер выводить, а остальное содержимое в [[*content]] ?


              ========== ВОПРОС СНЯТ — РАЗОБРАЛСЯ!=======================

              нужно было
              $modx->setPlaceholder('page.contents', $contents);
              $modx->setPlaceholder('page.text', $input);

              заменить на
              $modx->setPlaceholder('page.contents', $contents);
              return  $input;

              Спасибо! )
            Алексей
            13 ноября 2015, 11:21
            0
            Сниппетом пользуюсь давно и на нескольких сайтах. Спасибо автору!
            Но, у меня возникла необходимость разместить код Адсенс между списком оглавления страницы и самим контентом. А как это сделать — не знаю (в PHP не очень...).
            Может кто-нибудь подскажет, как правильно вставить код Адсенс в сниппет makeContents, чтобы рекламные блоки отображались между оглавлением и самой статьей.
            Спасибо!
              Александр
              06 февраля 2016, 13:28
              0
              У меня тегов
              </ul>
              появляется больше необходимого количества, на docs.modx.pro тоже самое.
              Как это поправить?
                yani
                08 декабря 2017, 22:51
                +1
                Вася спасибо) Буквально вчера писала такое на JS ) но на стороне сервера все равно лучше!
                Ксения
                04 октября 2018, 14:51
                0
                Доброго дня, добавлю: если в заголовке есть кавычки, то либо он не попадёт в выборку, либо всё будет выглядеть криво. Чтобы этого избежать
                заменяем
                $anchor = str_replace(' ', '-', $header);
                на
                $arr = array(
                    ' ' => '-',
                    '"' => '');
                $anchor = strtr($header,$arr);
                и ещё проблема, если есть одинаковые заголовки, то все анкоры будут для них одинаковыми, и соответственно, вести к первому из них. Как это решить не знаю. Нужна помощь. Возможно стоит как-то проставлять номер для каждого найденного сниппетом заголовка.
                  Yar
                  Yar
                  19 марта 2019, 15:29
                  0
                  Хороший компонент, но после каждой очистки кэша на странице появляется notice:
                  Notice: Undefined offset: 2 in /home/site.ru/public_html/core/components/jevix/model/jevix/jevix.class.php on line 135
                  Notice: Undefined offset: 3 in /home/site.ru/public_html/core/components/jevix/model/jevix/jevix.class.php on line 135
                  Как это убрать?
                    Dmytro Bochkov
                    18 апреля 2019, 17:49
                    0
                    Добрый день. Только планирую применить данный сниппет и возник вопрос: будет ли он корректно работать с PageBreaker?
                    Т.е. при переходе по ссылкам в сформированном оглавлении будет ли происходить переход на виртуальную страницу сгенерированную PageBreaker?
                      Dmytro Bochkov
                      14 мая 2019, 15:10
                      0
                      Ответ: только по ссылкам в пределах первой сгенерированной страницы. Ссылки на остальные страницы не формируются и соответственно перейти на них по ссылкам нельзя. А жаль. Буду что-то додумывать(
                      iWatchYouFromAfar
                      08 августа 2019, 15:36
                      +1
                      Решил воспользоваться наработкой Василия и прилепил jQuery функцию на плавный скролл до блоков. Оказалось не все так просто, пришлось немного доработать сниппет.

                      1. Для начала, после замены пробелов на тире, вырезаем все сомнительные символы, которые попадают в якорь:

                      $anchor = preg_replace( '/[^ \w-]/' , '' , $anchor);
                      Ставить нужно перед формированием оглавления.

                      2. Затем, в следующей строке добавляем дата атрибут, т.к. с атрибутом href, jQuery в данном случае отказывается работать:

                      $header = "<a href=\"{$base}#{$anchor}\" data-id=\"#{$anchor}\">{$header}</a>";

                      3. Ну и добавляем якорь в id:

                      $to[$i] = '<a name="' . $anchor . '" id="' . $anchor . '"></a>' . $headers[0][$i];

                      4. На закуску, готовая функция jQuery.

                      $(document).ready(function () {
                          $(".page-contents a").on("click", function(event) {
                              event.preventDefault();
                      
                              var anchor = $(this).data("id"), // Ищем якорь
                                  anchorPX = $(anchor).offset().top; // Определяем положение якоря
                              $('html, body').animate({scrollTop: anchorPX}, 600);
                          });
                      });

                      Вроде все описал. На моем сайте вроде все работает, проблем не встретил.
                      За помощь с решением проблемы с jQuery, спасибо @Евгений Webinmd
                        iWatchYouFromAfar
                        08 августа 2019, 15:57
                        +1
                        Вот второй вариант регулярки, та что выше вырезает кириллицу.

                        $anchor = preg_replace( '/[^a-zа-яё \-]/iu' , '' , $anchor);
                        Григорий
                        17 апреля 2020, 14:15
                        0
                        Привет.
                        Все прекрасно работает есть в Заголовке нет классов или ID.
                        Вопрос: как заставить игнорировать Class и ID, например:
                        <h2 id="my-heading" class="header">Заголовок H2</h2>
                          Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
                          22