Когда статья небольшая, оглавление ей обычно не нужно.
Но если материал постепенно вырастает до 10–15 экранов, найти нужный раздел становится сложнее. Особенно это заметно в технических статьях, где читателю часто нужна не вся публикация целиком, а конкретная часть:
- установка;
- настройка;
- решение ошибки;
- пример кода;
- тестирование;
- итоговый рабочий вариант.
На таком материале удобно иметь содержание примерно следующего вида:
Содержание
Почему возникла проблема
Как работает решение
Как создаются якоря
Как строится меню
Как добавить подсветку раздела
Полный код
Можно написать его вручную:
<nav>
<a href="/blog/my-article#problem">
Почему возникла проблема
</a>
<a href="/blog/my-article#solution">
Как работает решение
</a>
<a href="/blog/my-article#code">
Полный код
</a>
</nav>
Code language: HTML, XML (xml)А затем вручную расставить идентификаторы:
<h2 id="problem">
Почему возникла проблема
</h2>
<h2 id="solution">
Как работает решение
</h2>
<h2 id="code">
Полный код
</h2>
Code language: HTML, XML (xml)Для одной статьи это несложно.
Проблемы начинаются тогда, когда таких материалов становится много.
Почему ручное оглавление быстро становится неудобным
Предположим, статья уже опубликована, но через несколько недель мы добавили новый раздел:
<h2>Как проверить результат</h2>
Code language: HTML, XML (xml)Теперь нужно не забыть:
- придумать ему
id; - добавить ссылку в оглавление;
- поставить её в правильное место;
- проверить якорь;
- при изменении названия раздела обновить и оглавление.
Если статей десятки или сотни, поддерживать содержание вручную совершенно не хочется.
Тем более структура статьи уже существует в HTML.
Например:
<h2>Почему возникла проблема</h2>
<h2>Как работает решение</h2>
<h3>Как создаются якоря</h3>
<h3>Как строится меню</h3>
<h2>Полный код</h2>
Code language: HTML, XML (xml)Браузер уже располагает всей информацией, необходимой для построения оглавления.
Остаётся только собрать её автоматически.
Что мы решили сделать
Логика получилась довольно простой:
страница загрузилась
↓
находим контейнер статьи
↓
ищем внутри H2 и H3
↓
присваиваем им уникальные ID
↓
создаём ссылки
↓
строим вложенное оглавление
↓
ссылка прокручивает к нужному заголовку
↓
текущий раздел подсвечивается
В итоге автору вообще не нужно заниматься оглавлением.
Он просто пишет статью:
<h2>Установка</h2>
<p>...</p>
<h2>Настройка</h2>
<p>...</p>
<h3>Настройка кеша</h3>
<p>...</p>
Code language: HTML, XML (xml)а содержание создаётся автоматически.
Почему используем H2 и H3, но не H1
На странице статьи уже должен быть один главный заголовок:
<h1>
Как автоматически создать оглавление статьи
</h1>
Code language: HTML, XML (xml)Это название всей страницы.
Добавлять его ещё раз в содержание обычно нет смысла.
Основные разделы статьи логично размечать через:
<h2>
Code language: HTML, XML (xml)а подразделы через:
<h3>
Code language: HTML, XML (xml)Поэтому JavaScript ищет:
article.querySelectorAll('h2, h3');
Code language: JavaScript (javascript)В итоге получается нормальная структура:
H1 — название статьи
H2 — раздел
H3 — подраздел
H3 — подраздел
H2 — следующий раздел
Оглавление при этом строится из уже существующей семантической структуры страницы.
Сначала каждому заголовку нужен якорь
Чтобы перейти к определённому месту страницы, заголовку нужен id.
Например:
<h2 id="how-it-works">
Как это работает
</h2>
Code language: HTML, XML (xml)Тогда можно создать ссылку на этот раздел.
Но вручную писать id каждому заголовку мы как раз не хотим.
Поэтому JavaScript создаёт их автоматически.
Создаём ID из текста заголовка
Допустим, в статье есть:
<h2>Как это работает</h2>
Code language: HTML, XML (xml)Из текста:
Как это работает
получаем:
как-это-работает
и устанавливаем:
<h2 id="как-это-работает">
Как это работает
</h2>
Code language: HTML, XML (xml)Современные браузеры нормально работают с Unicode в идентификаторах, поэтому русские буквы необязательно транслитерировать.
Функция может выглядеть так:
function slugify(text) {
return text
.toLowerCase()
.trim()
.normalize('NFKC')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
}
Code language: JavaScript (javascript)Например:
Как работает MODX + Fenom?
превратится примерно в:
как-работает-modx-fenom
Что делать с одинаковыми заголовками
В одной статье вполне могут появиться два одинаковых заголовка:
<h2>Пример</h2>
...
<h2>Пример</h2>
Code language: HTML, XML (xml)Нельзя назначить обоим:
id="пример"
Code language: JavaScript (javascript)потому что идентификаторы на странице должны быть уникальными.
Поэтому мы делаем:
пример
пример-2
пример-3
и так далее.
Для этого достаточно хранить набор уже использованных ID.
Существующие ID лучше не менять
Иногда автор уже специально указал:
<h2 id="installation">
Установка
</h2>
Code language: HTML, XML (xml)Такой идентификатор лучше оставить.
Он может уже использоваться:
- во внешних ссылках;
- в документации;
- в закладках;
- в поисковой выдаче;
- в других статьях.
Поэтому правило простое:
у заголовка уже есть id
↓
оставляем
id отсутствует
↓
создаём автоматически
Строим структуру H2 → H3
Можно вывести все ссылки одним плоским списком:
Установка
Настройка
Настройка кеша
Настройка базы
Проверка
Но так теряется структура статьи.
Гораздо понятнее:
Установка
Настройка
Настройка кеша
Настройка базы
Проверка
Поэтому каждый H2 становится главным пунктом, а следующие за ним H3 — вложенными.
Например:
<h2>Настройка</h2>
<h3>Настройка кеша</h3>
<h3>Настройка базы</h3>
Code language: HTML, XML (xml)превращаются примерно в:
<ul>
<li>
<a href="...">
Настройка
</a>
<ul>
<li>
<a href="...">
Настройка кеша
</a>
</li>
<li>
<a href="...">
Настройка базы
</a>
</li>
</ul>
</li>
</ul>
Code language: HTML, XML (xml)Получается нормальное семантическое оглавление.
Важная проблема: <base> ломает обычные ссылки href="#section"
Здесь мы столкнулись с нюансом, который легко пропустить.
На многих сайтах в <head> используется:
<base href="https://example.com/">
Code language: HTML, XML (xml)В MODX такая конструкция тоже встречается довольно часто:
<base href="https://example.com/" />
Code language: HTML, XML (xml)Обычно она удобна: относительные пути к ресурсам и страницам разрешаются относительно указанного адреса.
Но у неё есть побочный эффект.
Если создать обычную ссылку:
<a href="#installation">
Installation
</a>
Code language: HTML, XML (xml)и текущая статья находится по адресу:
https://example.com/blog/my-article
Code language: JavaScript (javascript)можно ожидать перехода на:
https://example.com/blog/my-article#installation
Code language: JavaScript (javascript)Но из-за <base> браузер может разрешить ссылку относительно:
https://example.com/
Code language: JavaScript (javascript)и получить:
https://example.com/#installation
Code language: JavaScript (javascript)В результате вместо прокрутки внутри статьи пользователь попадает на главную страницу.
Для автоматического оглавления это критическая ошибка.
Почему мы не используем голый href="#..."
Самый распространённый пример TOC в интернете выглядит так:
link.href = '#' + id;
Code language: JavaScript (javascript)На сайте без <base> это работает.
На сайте с:
<base href="https://example.com/">
Code language: HTML, XML (xml)такое решение ненадёжно.
Поэтому мы формируем ссылку из текущего пути страницы:
link.href =
window.location.pathname +
window.location.search +
'#' +
encodeURIComponent(id);
Code language: JavaScript (javascript)Если статья открыта по адресу:
/blog/my-article
получится:
/blog/my-article#installation
Code language: PHP (php)Если есть GET-параметры:
/blog/my-article?lang=ru
они также сохранятся:
/blog/my-article?lang=ru#installation
Code language: PHP (php)Поскольку URL начинается с /, <base> уже не сможет перенаправить его на другой путь.
Дополнительно сохраняем ID в data-target
Для JavaScript-навигации нам вообще необязательно разбирать href.
Поэтому одновременно записываем:
link.dataset.target = id;
В HTML получается примерно:
<a
href="/blog/my-article#installation"
data-target="installation"
>
Установка
</a>
Code language: HTML, XML (xml)Теперь:
hrefостаётся нормальной рабочей ссылкой;<base>ей не мешает;- JavaScript получает чистый
idчерезdata-target.
Это удобно и для плавной прокрутки, и для подсветки текущего раздела.
Почему ссылка должна работать даже без JavaScript
Можно было вообще написать:
<a href="javascript:void(0)">
Code language: HTML, XML (xml)и делать всю навигацию через JavaScript.
Но это плохой вариант.
Если JavaScript по какой-либо причине не загрузится, содержание перестанет работать.
Поэтому мы сохраняем нормальный URL:
/blog/my-article#installation
Code language: PHP (php)Получается progressive enhancement:
JavaScript работает
↓
плавная прокрутка + active state
JavaScript не работает
↓
обычная HTML-якорная ссылка всё равно работает
Плавная прокрутка
При клике мы получаем ID:
const id = link.dataset.target;
Code language: JavaScript (javascript)находим заголовок:
const target = document.getElementById(id);
Code language: JavaScript (javascript)и прокручиваем:
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
Code language: CSS (css)Так мы вообще не зависим от того, как браузер интерпретирует href.
Обновляем URL после прокрутки
Если просто вызвать:
scrollIntoView()
пользователь окажется у нужного раздела, но URL страницы не изменится.
А нам полезно получить:
/blog/my-article#installation
Code language: PHP (php)Такую ссылку можно:
- скопировать;
- отправить другому человеку;
- сохранить в закладки;
- открыть напрямую.
Поэтому после прокрутки обновляем адрес:
history.pushState(
null,
'',
window.location.pathname +
window.location.search +
'#' +
encodeURIComponent(id)
);
Code language: JavaScript (javascript)Страница при этом не перезагружается.
Что делать с фиксированной шапкой
Есть ещё одна типичная проблема с якорями.
Если сайт имеет фиксированную шапку:
position: fixed;
Code language: HTTP (http)после перехода заголовок может оказаться под ней.
Решение очень простое:
.article-content h2,
.article-content h3 {
scroll-margin-top: 100px;
}
Code language: CSS (css)Теперь браузер оставляет перед заголовком нужный отступ.
Значение:
100px
нужно подобрать под высоту конкретной шапки.
Если статья короткая — оглавление можно не показывать
Для статьи с двумя небольшими разделами содержание может только занимать место.
Поэтому задаём минимальное количество заголовков:
const MIN_HEADINGS = 3;
Code language: JavaScript (javascript)И проверяем:
if (headings.length < MIN_HEADINGS) {
return;
}
Code language: JavaScript (javascript)На коротких материалах блок оглавления останется скрытым.
Подсвечиваем раздел, который сейчас читает пользователь
Оглавление становится удобнее, если текущий раздел визуально выделяется:
Установка
● Настройка
Настройка кеша
Настройка базы
Проверка
Логика достаточно простая.
Берём текущую позицию страницы:
const scrollPosition =
window.scrollY + 140;
Code language: JavaScript (javascript)и ищем последний заголовок, который уже оказался выше этой точки.
Затем сравниваем:
link.dataset.target
Code language: CSS (css)с:
currentHeading.id
Code language: CSS (css)и добавляем класс:
active
Важно, что здесь мы тоже не сравниваем href.
То есть наличие <base> вообще не влияет на scrollspy.
Почему не выполняем расчёт на каждый scroll
Событие:
scroll
может срабатывать очень часто.
Для небольшой статьи это не катастрофа, но выполнять одинаковые DOM-операции десятки раз за один кадр нет смысла.
Поэтому используем:
requestAnimationFrame()
и простой флаг:
let ticking = false;
Code language: JavaScript (javascript)Так обновление активного пункта выполняется максимум один раз на кадр.
Как это выглядит на компьютере
На широком экране содержание удобно разместить слева:
┌──────────────────┬─────────────────────────────┐
│ Содержание │ │
│ │ Текст статьи │
│ Раздел 1 │ │
│ Раздел 2 │ H2 Заголовок │
│ Подраздел │ │
│ Раздел 3 │ текст... │
│ │ │
└──────────────────┴─────────────────────────────┘
и сделать:
position: sticky;
top: 100px;
Code language: HTTP (http)Тогда оглавление остаётся рядом с читателем.
Как это работает на мобильном
На небольшом экране постоянная боковая колонка занимает слишком много места.
Поэтому через media query превращаем layout в одну колонку:
┌─────────────────────────────┐
│ Содержание │
│ │
│ Раздел 1 │
│ Раздел 2 │
│ Раздел 3 │
├─────────────────────────────┤
│ │
│ Статья │
│ │
└─────────────────────────────┘
Никакой отдельный мобильный JavaScript для этого не нужен.
Особенность технического блога на MODX + Fenom
В нашем случае статьи часто содержат примеры самого Fenom:
{if $author}
Code language: PHP (php)JSON:
{"strip": true}
Code language: JSON / JSON with Comments (json)PHP:
if ($result) {
return $result;
}
Code language: PHP (php)Если содержимое ресурса снова попадёт под Fenom parser, шаблонизатор может попытаться выполнить этот код.
Поэтому содержимое технической статьи мы выводим так:
{ignore}
[[*content]]
{/ignore}
Например:
<article
class="article-content js-article-content"
id="article-content"
>
{ignore}
[[*content]]
{/ignore}
</article>
Code language: HTML, XML (xml)Это позволяет спокойно публиковать внутри статьи примеры JSON, JavaScript, PHP и самого Fenom.
Что в итоге делает автор статьи
После установки системы автору вообще не нужно думать об оглавлении.
Достаточно правильно использовать заголовки:
<h2>Почему возникла проблема</h2>
<p>...</p>
<h2>Как её решить</h2>
<p>...</p>
<h3>Первый вариант</h3>
<p>...</p>
<h3>Второй вариант</h3>
<p>...</p>
<h2>Полный код</h2>
Code language: HTML, XML (xml)После загрузки страницы JavaScript сам:
находит H2/H3
↓
создаёт уникальные ID
↓
строит H2 → H3 структуру
↓
создаёт ссылки с текущим URL
↓
не ломается из-за <base>
↓
включает плавную прокрутку
↓
обновляет hash в адресной строке
↓
подсвечивает текущий раздел
Code language: HTML, XML (xml)Структура самой статьи становится единственным источником данных для оглавления.
Полный код
Ниже готовый вариант без jQuery и сторонних библиотек.
HTML / Fenom-шаблон
<section class="o-container u-padding -medium-bottom">
<div class="article-layout">
<aside class="article-sidebar">
<nav
class="article-toc js-article-toc"
aria-label="Оглавление статьи"
hidden
>
<div class="article-toc__title">
Содержание
</div>
<ul
class="article-toc__list js-article-toc-list"
></ul>
</nav>
</aside>
<article
class="article-content js-article-content"
id="article-content"
>
{ignore}
[[*content]]
{/ignore}
</article>
</div>
</section>
Code language: HTML, XML (xml)
CSS
html {
scroll-behavior: smooth;
}
/* ==========================
Article layout
========================== */
.article-layout {
display: grid;
grid-template-columns:
minmax(200px, 260px)
minmax(0, 1fr);
gap: 50px;
align-items: start;
}
/* ==========================
Table of contents
========================== */
.article-toc {
position: sticky;
top: 100px;
padding: 20px;
border: 1px solid #e7e7e7;
border-radius: 8px;
}
.article-toc__title {
margin-bottom: 15px;
font-size: 18px;
font-weight: 600;
}
.article-toc__list,
.article-toc__sublist {
margin: 0;
padding: 0;
list-style: none;
}
.article-toc__sublist {
margin-top: 7px;
padding-left: 15px;
}
.article-toc__item {
margin: 7px 0;
}
.article-toc__link {
display: block;
font-size: 14px;
line-height: 1.4;
text-decoration: none;
opacity: 0.7;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.article-toc__link:hover,
.article-toc__link.active {
opacity: 1;
}
.article-toc__link.active {
font-weight: 600;
transform: translateX(3px);
}
/* ==========================
Anchor offset
========================== */
.article-content h2,
.article-content h3 {
scroll-margin-top: 100px;
}
/* ==========================
Mobile
========================== */
@media (max-width: 900px) {
.article-layout {
grid-template-columns: 1fr;
gap: 30px;
}
.article-toc {
position: static;
}
}
Code language: CSS (css)JavaScript
document.addEventListener(
'DOMContentLoaded',
function () {
const article =
document.querySelector(
'.js-article-content'
);
const toc =
document.querySelector(
'.js-article-toc'
);
const tocList =
document.querySelector(
'.js-article-toc-list'
);
if (
!article ||
!toc ||
!tocList
) {
return;
}
/*
* Основные разделы статьи — H2,
* подразделы — H3.
*/
const headings = Array.from(
article.querySelectorAll(
'h2, h3'
)
).filter((heading) => {
return (
heading.textContent.trim()
!== ''
);
});
/*
* На коротких статьях
* оглавление не показываем.
*/
const MIN_HEADINGS = 3;
if (
headings.length
< MIN_HEADINGS
) {
return;
}
/*
* Создание slug.
*
* Поддерживает Unicode,
* поэтому работает и с кириллицей.
*/
function slugify(text) {
return text
.toLowerCase()
.trim()
.normalize('NFKC')
.replace(
/[^\p{L}\p{N}]+/gu,
'-'
)
.replace(
/^-+|-+$/g,
''
);
}
/*
* Использованные ID.
*/
const usedIds = new Set();
/*
* Сначала учитываем ID,
* которые уже были указаны вручную.
*/
headings.forEach(
(heading) => {
if (heading.id) {
usedIds.add(
heading.id
);
}
}
);
/*
* Создаём уникальный ID
* только если его ещё нет.
*/
function createUniqueId(
heading,
index
) {
if (heading.id) {
return heading.id;
}
let base = slugify(
heading.textContent
);
if (!base) {
base =
'section-'
+ (index + 1);
}
let id = base;
let counter = 2;
while (
usedIds.has(id)
) {
id =
base
+ '-'
+ counter;
counter++;
}
usedIds.add(id);
heading.id = id;
return id;
}
/*
* Создаём ссылку.
*
* ВАЖНО:
*
* Не используем:
*
* href="#section"
*
* потому что при наличии
*
* <base href="...">
*
* браузер может разрешить такой URL
* относительно base URL.
*
* Вместо этого явно указываем
* текущий pathname + query.
*/
function createLink(
heading,
index
) {
const id =
createUniqueId(
heading,
index
);
const link =
document.createElement(
'a'
);
link.className =
'article-toc__link';
link.href =
window.location.pathname
+ window.location.search
+ '#'
+ encodeURIComponent(id);
/*
* Для JavaScript-навигации
* используем настоящий ID,
* а не разбираем href.
*/
link.dataset.target = id;
link.textContent =
heading.textContent.trim();
return link;
}
/*
* Строим структуру:
*
* H2
* H3
* H3
* H2
*/
let currentH2Item = null;
headings.forEach(
(heading, index) => {
const item =
document.createElement(
'li'
);
item.className =
'article-toc__item';
const link =
createLink(
heading,
index
);
item.appendChild(link);
/*
* H2 — основной раздел.
*/
if (
heading.tagName
.toLowerCase()
=== 'h2'
) {
tocList.appendChild(
item
);
currentH2Item =
item;
return;
}
/*
* H3 — подраздел.
*/
if (
heading.tagName
.toLowerCase()
=== 'h3'
) {
/*
* Если H3 встретился
* раньше первого H2,
* добавляем обычным пунктом.
*/
if (
!currentH2Item
) {
tocList
.appendChild(
item
);
return;
}
let sublist =
currentH2Item
.querySelector(
':scope > '
+ '.article-toc__sublist'
);
if (!sublist) {
sublist =
document
.createElement(
'ul'
);
sublist.className =
'article-toc__sublist';
currentH2Item
.appendChild(
sublist
);
}
sublist.appendChild(
item
);
}
}
);
/*
* Оглавление построено —
* теперь показываем его.
*/
toc.hidden = false;
const links = Array.from(
toc.querySelectorAll(
'.article-toc__link'
)
);
/*
* =================================
* Навигация по клику
* =================================
*
* Не полагаемся на стандартную
* обработку href браузером.
*
* Поэтому <base> нам уже
* не мешает.
*/
toc.addEventListener(
'click',
function (event) {
const link =
event.target.closest(
'.article-toc__link'
);
if (!link) {
return;
}
const id =
link.dataset.target;
if (!id) {
return;
}
const target =
document.getElementById(
id
);
if (!target) {
return;
}
event.preventDefault();
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
/*
* Обновляем URL,
* но не перезагружаем страницу.
*/
history.pushState(
null,
'',
window.location.pathname
+ window.location.search
+ '#'
+ encodeURIComponent(id)
);
}
);
/*
* =================================
* Подсветка текущего раздела
* =================================
*/
function updateActiveLink() {
const scrollPosition =
window.scrollY + 140;
let currentHeading =
headings[0];
headings.forEach(
(heading) => {
if (
heading.offsetTop
<= scrollPosition
) {
currentHeading =
heading;
}
}
);
links.forEach(
(link) => {
const isActive =
link.dataset.target
=== currentHeading.id;
link.classList.toggle(
'active',
isActive
);
}
);
}
/*
* Ограничиваем количество
* вычислений во время scroll.
*/
let ticking = false;
window.addEventListener(
'scroll',
function () {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(
function () {
updateActiveLink();
ticking = false;
}
);
},
{
passive: true
}
);
/*
* Начальное состояние.
*/
updateActiveLink();
}
);
Code language: JavaScript (javascript)Что получилось
После этой доработки статья сама становится источником данных для навигации:
H2 / H3
↓
автоматические уникальные ID
↓
оглавление
↓
ссылки на текущую страницу
↓
защита от проблем с <base>
↓
плавная прокрутка
↓
URL с #section
↓
подсветка текущего раздела
Code language: HTML, XML (xml)Автору не нужно вручную:
- создавать оглавление;
- придумывать якоря;
- синхронизировать названия;
- обновлять ссылки после редактирования;
- учитывать наличие
<base>.
Добавили новый:
<h2>
Code language: HTML, XML (xml)— появился новый раздел содержания.
Добавили:
<h3>
Code language: HTML, XML (xml)— появился подраздел.
Изменили текст заголовка — оглавление обновилось автоматически.
И самое главное: решение не зависит от MODX. JavaScript работает уже с готовым HTML, поэтому тот же принцип можно использовать в WordPress, статическом сайте или практически любой другой CMS.
Для MODX + Fenom остаётся только один дополнительный нюанс: содержимое технических статей лучше изолировать от шаблонизатора:
{ignore}
[[*content]]
{/ignore}
чтобы примеры JSON, PHP, JavaScript и Fenom внутри <code> оставались кодом для читателя, а не пытались выполняться сервером.
