Inhaltsverzeichnis in WordPress ohne Plugin erstellen

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.

  1. 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
  2. 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:

<!-- 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) {
                    console.log(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
    };
})();

Geschrieben von Konstantin Hanke

Benutzerbild

Konstantin arbeitet als Webentwickler bei kulturbanause. Seine Hauptaufgabe ist die technische Umsetzung von klaren, soliden und effizienten Webauftritten und Website-Komponenten. Darüber hinaus kümmert er sich um die Wartung, Optimierung und Weiterentwicklung von bestehenden Websites. Sein besonderes Interesse gilt der Idee von quelloffener, freier Software.

Feedback & Ergänzungen – Schreibe einen Kommentar

Kommentar zu dieser Seite

Wir freuen uns über Anregungen, Ergänzungen oder Hinweise zu Fehlern. Wir lesen jeden Eintrag, veröffentlichen aber nur, was den Inhalt sinnvoll ergänzt.

WordPress-Projekte mit kulturbanause

Wir wissen wovon wir reden. Wir setzen WordPress seit über 10 Jahren erfolgreich ein und realisieren maßgeschneiderte Websites auf Basis dieses großartigen CMS.

WordPress-Leistungsangebot →

Schulungen von kulturbanause

Wir bieten Seminare und Workshops zu den Themen Konzept, Design und Development. Immer up-to-date, praxisnah, kurzweilig und mit dem notwendigen Blick über den Tellerrand.

Übersicht Schulungsthemen →