Inhaltsverzeichnis in WordPress ohne Plugin erstellen
Ein Inhaltsverzeichnis in der Detailansicht eines WordPress-Beitrags hilft dabei, die Struktur der Unterseite zu erfassen. Sorgfältig umgesetzt, verbessert sich so die UX, die Barrierefreiheit und die Suchmaschinenoptimierung.
Ein Inhaltsverzeichnis (engl. Table of Contents oder kurz TOC) ist eine Liste von Kapiteln oder Abschnittsüberschriften in einem Artikel. Es befindet sich in der Regel am Anfang, direkt nach der Einleitung. Dies ist insbesondere bei längeren Beiträgen sinnvoll, da es Interessierten hilft, den Inhalt und die Gliederung direkt zu erfassen.
Durch die Auflistung der Hauptabschnitte eines Textes und die Verknüpfung mit Sprungmarken ermöglicht das Inhaltsverzeichnis eine gezielte Navigation durch das Dokument. Darüber hinaus unterstützt ein TOC die Suchmaschinenoptimierung (SEO), indem es klare Strukturen für die Indexierung der Seite bereitstellt und die Auffindbarkeit relevanter Inhalte verbessert.
Im Folgenden beschreiben wir verschiedene Ansätze, wie ein Inhaltsverzeichnis in WordPress, in statische Websites bzw. andere CMS integriert werden kann.
Verschiedene Ansätze für die Umsetzung
Es gibt verschiedene Möglichkeiten, den benötigen HTML-Code dynamisch aus dem vorhandenen Inhalt zu generieren und an der richtigen Stelle im Dokument zu platzieren. Wir schauen uns alle Möglichkeiten mit den jeweiligen Vor- und Nachteilen an.
- Zunächst muss das HTML-Markup für das Inhaltsverzeichnis erzeugt werden:
- Eine Herangehensweise erfasst alle Überschriften mittels Javascript und fügt das Inhaltsverzeichnis clientseitig in die Seite ein.
- Die klassische Variante verwendet einen WordPress-Filter und fügt das Inhaltsverzeichnis mittels PHP in den Content-Bereich ein.
- Als dritte Möglichkeit steht ein nativer WordPress-Block zur Verfügung
- Sobald das Markup vorhanden ist, kann das Verzeichnis mit CSS gestaltet werden. Hier gehen wir in vor allem auf das Hervorheben des aktuell angesteuerten Sinnabschnitts ein
Anforderungen an den HTML-Code des Inhaltsverzeichnisses
Wir gehen für dieses Beispiel davon aus, dass das Inhaltsverzeichnis in WordPress (oder einem anderen CMS) automatisch aus dem Inhalt der Seite erzeugt und als Liste zwischen Seitentitel und Hauptinhalt eingefügt werden soll. Die Listenpunkte des Inhaltsverzeichnisses entsprechen den Überschriften im Dokument und führen über Sprungmarken direkt zum entsprechenden Abschnitt. Dafür benötigen die relevanten Überschriften eine ID. Im Blockeditor von WordPress können diese für Überschriften unter der Block-Einstellung »HTML-Anker« vergeben werden.
Um die Komplexität im Beispiel gering zu halten, beschränken wir uns hier auf Überschriften einer Ebene (<h2>
). Alle Überschriften befinden sich in einem bestimmten Inhaltsbereich, der mit einem Data-Attribut (hier <article data-toc-content>
) gekennzeichnet ist. Als wichtiges Navigationselement muss das TOC natürlich auch barrierefrei umgesetzt werden.
Diese Anforderungen für Barrierefreiheit sind mit dem folgenden Markup erfüllt:
- Die Verwendung des HTML-Elements
<nav>
kennzeichnet den Abschnitt als Navigationsbereich. Mit der Vergabe eines eindeutigen Werts für dasaria-label
-Attribut wird der Bereich mit assistiven Technologien nutzbar. - Das Data-Attribut
data-toc-container
wird später für die dynamische Anpassung des TOC mittels JavaScript verwendet, so dass CSS-Klassen ausschließlich für Styling genutzt werden können. - Der Einsatz des Ordered List-Elements
<ol>
verdeutlicht, dass die darin enthaltenen Sprungmarken in einer festgelegten Reihenfolge Sinn ergeben.
<!-- Beispiel des gewünschten HTML-Markups für das TOC -->
<nav aria-label="Inhaltsverzeichnis" data-toc-container>
<ol>
<li>
<a href="#abschnitt-1">Abschnitt 1</a>
</li>
<li>
<a href="#abschnitt-2">Abschnitt 2</a>
</li>
<li>
<a href="#abschnitt-3">Abschnitt 3</a>
</li>
</ol>
</nav>
Ansatz 1: Markup mit JavaScript erzeugen
Die clientseitige Umsetzung eines Inhaltsverzeichnisses mit JavaScript ist naheliegend. Dabei wird das Inhaltsverzeichnis dynamisch bei der Ausgabe des Inhalts erzeugt und eingefügt. Dazu wird im Skript zunächst geprüft, ob ein mit dem Attribut data-toc-content
markierter Inhaltsbereich existiert. Ist dies der Fall, wird ein neu erzeugtes Nav-Element mit den oben als Voraussetzung beschriebenen Attributen hinzugefügt. Anschließend werden alle relevanten Überschriften (<h2>
) aus dem Inhalt ausgelesen. Für alle Überschriften, die mit einer ID versehen sind, wird dann ein Ankerlink auf diese ID erzeugt, wobei jeweils der Text der Überschrift als Linktext verwendet wird. Dieser Link wird dann als Inhalt eines Listenelements zu einer geordneten Liste hinzugefügt, die schließlich das fertige Inhaltsverzeichnis ergibt.
/**
* PAGE LOAD
*/
document.addEventListener("DOMContentLoaded", function() {
toc.init();
});
/**
* TOC
*/
let toc = (function () {
"use strict";
let toc = {};
/**
* Initialise
*/
let init = function () {
toc.content = document.querySelector('[data-toc-content]');
if (toc.content) {
prepareTOC();
}
};
/**
* Prepare TOC
*/
let prepareTOC = function () {
/* Get relevant headings and prepare for TOC */
toc.headings = toc.content.querySelectorAll('h2');
if (toc.headings.length > 1) {
/* Create NAV element */
toc.container = document.createElement('nav');
toc.container.setAttribute('data-toc-container', '');
toc.container.setAttribute('aria-label', 'Table of contents');
/* Create OL element */
toc.list = document.createElement('ol');
/* Iterate over headings, create LI and A elements for each */
toc.headings.forEach(function (heading) {
if (heading.id) {
let item = {};
item.el = heading;
item.slug = heading.id;
item.toc = document.createElement('li');
item.link = document.createElement('a');
item.link.href = '#' + item.slug;
item.link.setAttribute('data-id', item.slug);
item.link.textContent = item.el.textContent;
item.toc.appendChild(item.link);
toc.list.appendChild(item.toc);
}
});
/* Append list to TOC container */
toc.container.appendChild(toc.list);
toc.content.parentNode.insertBefore(toc.container, toc.content);
}
};
/**
* Return
*/
return {
init: init
};
})();
Diese Variante kann – je nach Layout – beim Seitenaufbau zu Problemen führen. Abhängig von der Ladegeschwindigkeit, dem Umfang der Inhalte und der Positionierung des TOC kann es durch die dynamische Veränderung der Inhalte zu sogenannten Layout-Shifts kommen. Das unschöne Verschieben bereits sichtbarer Inhalte während des Seitenaufbaus führt zu einer ungünstigen User Experience (UX) und wird auch von Suchmaschinen zunehmend negativ bewertet – z.B. in den Core Web Vitals von Google.
Ansatz 2: Markup mit PHP erzeugen
Die serverseitige Umsetzung mit PHP hat den Vorteil, dass der Code vollständig generiert an den Browser ausgeliefert wird. Dies gewährleistet eine flüssige Darstellung und ermöglicht das Caching des gesamten Dokuments. Das Markup des Inhalts muss vor der Ausgabe eingelesen und modifiziert werden. Die Ausgabe des eigentlichen Contents kann dazu mit einem PHP-Buffer (ob_start
und ob_end_flush
) verzögert werden, so dass die gewünschte Manipulation erfolgen kann. In WordPress steht dafür alternativ der Filter the_content
zur Verfügung, den wir in der functions.php benutzen können.
Unter Verwendung der php-Klasse »DOMDocument« gehen wir den gesamten Inhalt durch und extrahieren mit $doc->getElementsByTagName('h2')
alle h2-Überschriften. Diese prüfen wir dann auf die Existenz einer ID und hinterlegen im positiven Fall jeweils ID und Titel als Key und Value in einem assoziativen Array $toc_items
. Wenn das Array nicht leer ist, es also relevante Überschriften gibt, erstellen wir das Markup für das TOC. Dafür iterieren wir mit foreach
über das Array und erstellen für jeden Eintrag ein neues <li>
-Element samt Ankerlink darin, wobei wir die ID als Linkziel und den Text als Linktext verwenden.
Anschließend wird das neu generierte TOC dem Inhalt vorangestellt und die Ausgabe fortgesetzt. Im Browser erscheint dann das Inhaltsverzeichnis vor dem Hauptinhalt der Seite.
<?php
add_filter('the_content', function ($content) {
// Parse content, get headings and build TOC
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $content, LIBXML_NOERROR);
$toc_items = array();
$toc = false;
// Extract headings from the content
foreach($doc->getElementsByTagName('h2') as $element) {
if ($element->getAttribute('id')) {
$toc_items[$element->getAttribute('id')] = $element->textContent;
}
}
// Generate Table of Contents (TOC) HTML
if (!empty($toc_items)) {
$toc .= '<nav aria-label="' . __('Table of contents', 'kb') . '" data-toc-container>' .
'<ol>';
foreach ($toc_items as $id => $text) {
$toc .= '<li>' .
'<a href="#'. $id . '" data-id="' . $id . '">' .
$text .
'</a>'.
'</li>';
}
$toc .= '</ol>' .
'</nav>';
}
// Append TOC to content if TOC exists
if ($toc) {
$content = $toc . $content;
}
return $content;
});
?>
Ansatz 3: Markup mit WordPress-Block erzeugen
Ein Nachteil der beiden zuvor beschriebenen Ansätze ist, dass in WordPress das Inhaltsverzeichnis im Block-Editor nicht sichtbar ist und nicht beliebig positioniert werden kann. Genau diese Funktionalität bietet der native Table Of Contents-Block von WordPress. Dieser ist zum Veröffentlichungszeitpunkt dieses Artikels allerdings nur über das Gutenberg-Plugin verfügbar, dass der offiziellen WordPress-Version um einige Features voraus ist.
Der Block nimmt automatisch alle Überschriften jeder Hierarchieebene in das Inhaltsverzeichnis auf. Es ist jedoch nicht möglich, einzelne Überschriften auszuschließen. Es ist auch nicht möglich, den TOC-Elementen Attribute zuzuweisen. So kann z.B. kein aria-label
für das umgebende Nav-Element vergeben werden, weshalb die Navigation nicht barrierefrei ist. Ebenso können keine Datenattribute zugewiesen werden, die in JavaScript zum Hervorheben der aktiven Menüpunkte verwendet werden können. Die Verwendung von Klassen wäre hier jedoch ein einfach umsetzbarer Workaround.
Der TOC-Block bietet vielversprechende Ansätze, scheint aber noch nicht ganz ausgereift zu sein. Man darf auf die weitere Entwicklung gespannt sein.
Aktuellen Abschnitt hervorheben
Das Inhaltsverzeichnis, das mit einer der oben beschriebenen Methoden erstellt wurde, kann bereits verwendet werden. Durch weitere Anpassungen kann die UX des Navigationselements jedoch noch verbessert werden.
Typischerweise wird ein TOC seitlich neben dem Inhalt oder am oberen Bildschirmrand fixiert. Auf diese Weise bleiben die Sprungmarken zu den verschiedenen Abschnitten des Inhalts beim Scrollen immer erreichbar. Dies lässt sich leicht mit CSS realisieren:
[data-toc-container] {
position: sticky;
top: 0;
}
Wenn das Inhaltsverzeichnis stets sichtbar ist, macht es Sinn den aktuell im Sichtfeld befindlichen Seitenabschnitt optisch hervorzuheben. Dazu verwenden wir einen Intersection Observer in JavaScript, den wir mit let observer = new IntersectionObserver(callback, options);
erstellen. In den Optionen hinterlegen wir die Angabe rootMargin: '0px 0px -66% 0px'
. Dadurch wird sichergestellt, dass das Auftreten von Zielelementen nur im oberen Drittel des Bildschirms beobachtet wird. In der Callback-Funktion wird definiert, was mit den Elementen geschehen soll, wenn sie im kritischen Bereich erscheinen oder diesen verlassen.
Der unten stehende Code sorgt dafür, dass die letzte Überschrift, die in den beobachteten Bereich eintritt, den entsprechenden Punkt im Inhaltsverzeichnis mit dem Data-Attribut data-toc-current
versieht. Damit können wir die visuelle Hervorhebung in CSS steuern. Verlässt eine Überschrift den kritischen Bereich, wird zusätzlich geprüft, ob sich noch eine weitere relevante Überschrift innerhalb des kritischen Bereichs befindet. Ist dies der Fall, wird diese als aktueller Punkt im TOC markiert.
[data-toc-current] {
background-color: red;
}
// Code für das Highlighting des aktiven Abschnitts im TOC
/**
* PAGE LOAD
*/
document.addEventListener("DOMContentLoaded", function() {
toc.init();
});
/**
* TOC
*/
let toc = (function () {
"use strict";
let toc = {};
/**
* Initialise
*/
let init = function () {
toc.content = document.querySelector('[data-toc-content]');
if (toc.content) {
updateTOC();
}
};
/**
* Update TOC
*/
let updateTOC = function () {
toc.container = toc.container ?? toc.content.querySelector('[data-toc-container]');
toc.list = toc.list ?? toc.container.querySelector('ol');
if (toc.container && toc.list) {
toc.headings = toc.headings ?? toc.content.querySelectorAll('h2');
toc.listLinks = toc.list.querySelectorAll('a');
/* Define intersection observer */
let observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
/* Heading comes into focus */
toc.listLinks.forEach(function (link) {
if (link.getAttribute('data-id') === entry.target.id) {
link.closest('li').setAttribute('data-toc-current', '');
} else {
link.closest('li').removeAttribute('data-toc-current');
}
});
} else {
/* Heading leaves focus */
if (entry.boundingClientRect.y > 0) {
/* Do this, when scrolling down */
let currentListItem = toc.list.querySelector('[data-toc-current]');
/* Check, if there is a highlighted item and if it belongs to the heading, that moving out of focus */
if (currentListItem && currentListItem.querySelector('[data-id]') && currentListItem.querySelector('[data-id]').getAttribute('data-id') === entry.target.id) {
/* Remove highlighting */
currentListItem.removeAttribute('data-toc-current');
/* Highlight previous sibling, when existent */
if (currentListItem.previousElementSibling) {
currentListItem.previousElementSibling.setAttribute('data-toc-current', '');
}
}
}
}
});
}, {
rootMargin: '0px 0px -66% 0px',
});
/* Initialize intersection observer for headings */
toc.headings.forEach(function (heading) {
observer.observe(heading);
});
}
};
/* Return */
return {
init: init
};
})();
Vollständiger JavaScript-Code für das Erzeugen des TOC sowie das Hervorheben des aktuellen Abschnitts
Wenn ihr, wie in Ansatz 1 gezeigt, auch das das Markup via JavaScript erzeugt, müsst ihr beide Scripte (für die Erzeugung sowie für das Highlighting) kombinieren.
// Kombinierter Code für das Erzeugen des TOC sowie für das Highlighting des aktiven Abschnitts
/**
* PAGE LOAD
*/
document.addEventListener("DOMContentLoaded", function() {
toc.init();
});
/**
* TOC
*/
let toc = (function () {
"use strict";
let toc = {};
/**
* Initialise
*/
let init = function () {
toc.content = document.querySelector('[data-toc-content]');
if (toc.content) {
prepareTOC();
updateTOC();
}
};
/**
* Prepare TOC
*/
let prepareTOC = function () {
/* Get relevant headings and prepare for TOC */
toc.headings = toc.content.querySelectorAll('h2');
if (toc.headings.length > 1) {
/* Create NAV element */
toc.container = document.createElement('nav');
toc.container.setAttribute('data-toc-container', '');
toc.container.setAttribute('aria-label', 'Table of contents');
/* Create OL element */
toc.list = document.createElement('ol');
/* Iterate over headings, create LI and A elements for each */
toc.headings.forEach(function (heading) {
if (heading.id) {
let item = {};
item.el = heading;
item.slug = heading.id;
item.toc = document.createElement('li');
item.link = document.createElement('a');
item.link.href = '#' + item.slug;
item.link.setAttribute('data-id', item.slug);
item.link.textContent = item.el.textContent;
item.toc.appendChild(item.link);
toc.list.appendChild(item.toc);
}
});
/* Append list to TOC container */
toc.container.appendChild(toc.list);
toc.content.parentNode.insertBefore(toc.container, toc.content);
}
};
/**
* Update TOC
*/
let updateTOC = function () {
toc.container = toc.container ?? toc.content.querySelector('[data-toc-container]');
toc.list = toc.list ?? toc.container.querySelector('ol');
if (toc.container && toc.list) {
toc.headings = toc.headings ?? toc.content.querySelectorAll('h2');
toc.listLinks = toc.list.querySelectorAll('a');
/* Define intersection observer */
let observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
/* Heading comes into focus */
toc.listLinks.forEach(function (link) {
if (link.getAttribute('data-id') === entry.target.id) {
link.closest('li').setAttribute('data-toc-current', '');
} else {
link.closest('li').removeAttribute('data-toc-current');
}
});
} else {
/* Heading leaves focus */
if (entry.boundingClientRect.y > 0) {
/* Do this, when scrolling down */
let currentListItem = toc.list.querySelector('[data-toc-current]');
/* Check, if there is a highlighted item and if it belongs to the heading, that moving out of focus */
if (currentListItem && currentListItem.querySelector('[data-id]') && currentListItem.querySelector('[data-id]').getAttribute('data-id') === entry.target.id) {
/* Remove highlighting */
currentListItem.removeAttribute('data-toc-current');
/* Highlight previous sibling, when existent */
if (currentListItem.previousElementSibling) {
currentListItem.previousElementSibling.setAttribute('data-toc-current', '');
}
}
}
}
});
}, {
rootMargin: '0px 0px -66% 0px',
});
/* Initialize intersection observer for headings */
toc.headings.forEach(function (heading) {
observer.observe(heading);
});
}
};
/**
* Return
*/
return {
init: init
};
})();