5 min read

Ghost CMS - Table of contents for posts and pages

Ghost CMS lacks a native table of contents (ToC) feature, but with a simple script, you can automatically generate a dynamic ToC for your posts and pages. This guide walks you through the implementation step by step.
Ghost CMS - Table of contents for posts and pages

🇩🇪 In deutsch lesen

Why a custom table of contents?

This site is based on the Ghost CMS. Ghost is open source, wonderfully modular, and highly customizable. However, what it lacks is a post module for a table of contents. Such a standard module simply does not exist.

The Ghost website does describe a method (see here), but it requires modifications to the theme and is relatively complex and not update-proof to implement.

An interesting Reddit post (see here) gave me the idea of creating a modularly integrated table of contents. It should be easily inserted into any post with a single line and automatically generate and update itself.

I have taken the code base from Reddit, rewrote it for performance and generalization, extended and optimized it accordingly. Finally modified the style sheets to use the accent color and do a visible more attractive indentation.

Thanks to NormisB for the inspiration!


Conceptual Thoughts

The table of contents uses hashtags in posts to define a post-specific language alongside the global system language (e.g., #de, #en).

The language of the table of contents heading is determined in three steps:

  1. By checking the post's hashtag.
  2. If no hashtag defines a known language, the html-lang element (the global system language) is used.
  3. If no valid language is detected, English is used as the default.

Additionally, the table of contents automatically searches for headings in the post and lists them hierarchically based on their level (h2, h3, etc.).

Tag lists from some Ghost themes are excluded since they are usually not part of the actual content.

A notable exception is the popular Liebling theme, which deviates from Ghost standards regarding CSS classes. This has also been considered.


Installation

Integrating the table of contents is incredibly simple and requires only two steps:

1. Add CSS Styles

Insert the following CSS styles into the <head> section of your Ghost site. You can do this via the admin panel under Settings → Code Injection → Head.

<style>
/* Container for the table of contents */
.gh-toc-container {
  margin-bottom: 20px; /* Space below the container */
  padding: 15px; /* Inner padding */
  background: var(--ghost-accent-bg, #f9f9f9); /* Background color, can be customized using a CSS variable */
  border-left: 4px solid var(--ghost-accent-color, #007acc); /* Left border for visual emphasis */
  border-radius: 6px; /* Rounded corners */
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); /* Subtle shadow for better separation */
}

/* Title of the table of contents */
.gh-toc-title {
  font-size: 1.4em; /* Larger font size */
  font-weight: bold; /* Bold font */
  margin-bottom: 12px; /* Space below the title */
  color: var(--ghost-heading-color, #222); /* Title color */
}

/* Main list of the table of contents */
.gh-toc {
  list-style: none; /* Removes default bullet points or numbering */
  padding-left: 0; /* Removes default indentation */
  margin: 0; /* Removes default spacing */
}

/* Top-level list items */
.gh-toc > li {
  margin-bottom: 8px; /* Space between main list items */
  font-size: 1.05em; /* Slightly larger font size */
  font-weight: 600; /* Slightly bolder text for better readability */
}

/* Nested lists (sub-items) */
.gh-toc ul {
  padding-left: 18px; /* Indentation for sub-items */
  border-left: 2px solid var(--ghost-border-color, #ddd); /* Thin left border for structure */
  margin-top: 6px; /* Space above nested lists */
}

/* Styling for sub-list items */
.gh-toc ul li {
  font-size: 0.95em; /* Smaller font size for sub-items */
  font-weight: 400; /* Normal font weight */
  position: relative; /* Positioning for list symbol */
  margin-bottom: 6px; /* Space between sub-items */
  padding-left: 10px; /* Light indentation for cleaner structure */
}

/* Small circles as bullet points for sub-items */
.gh-toc ul li::before {
  content: "•"; /* Bullet point as symbol */
  position: absolute; /* Absolute positioning relative to the list item */
  left: -12px; /* Positioning left of the text */
  color: var(--ghost-accent-color, #007acc); /* Color of the bullet point */
  font-size: 1.2em; /* Size of the bullet point */
  line-height: 1; /* Vertical alignment */
}

/* Styling for links in the table of contents */
.gh-toc a {
  text-decoration: none; /* Removes underline */
  color: var(--ghost-text-color, #444); /* Default text color */
  transition: color 0.2s ease-in-out, transform 0.1s ease-in-out; /* Smooth color and movement effect */
}

/* Hover effect for links */
.gh-toc a:hover {
  text-decoration: underline; /* Underline on hover */
  color: var(--ghost-link-hover-color, #005f99); /* Changes color on hover */
  transform: translateX(3px); /* Slight movement to the right for a dynamic effect */
}
</style>

2. Add JavaScript Code

Similarly, the following JavaScript code must be added to the footer section (under Settings → Code Injection → Footer).

<script>
document.addEventListener('DOMContentLoaded', function () {
    // Find all placeholders for the table of contents (TOC)
    const tocPlaceholders = document.querySelectorAll('.toc-placeholder');

    // Translations for the TOC title in different languages
    const tocTitles = {
        de: "Inhaltsverzeichnis", fr: "Table des matières", es: "Tabla de contenido",
        it: "Indice", nl: "Inhoudsopgave", pl: "Spis treści", pt: "Índice",
        ru: "Оглавление", zh: "目录", ja: "目次", ar: "جدول المحتويات",
        en: "Table of Contents", default: "Table of Contents"
    };

    // Allowed language codes for detecting language from body class
    const allowedTagLangs = new Set(Object.keys(tocTitles));

    // Function to detect language based on body class
    function getLanguageFromBodyClass() {
        return [...document.body.classList]
            .find(cls => cls.startsWith("tag-hash-") && allowedTagLangs.has(cls.replace("tag-hash-", "")))
            ?.replace("tag-hash-", "") || null;
    }

    // Determine the document language with priority:
    // 1) If a valid `tag-hash-XX` class exists, use it
    // 2) If not, check the <html lang="XX"> attribute
    // 3) If nothing is found, default to English
    let docLang = getLanguageFromBodyClass()
        || (allowedTagLangs.has(document.documentElement.lang.split("-")[0]) ? document.documentElement.lang.split("-")[0] : null)
        || "default";

    // Set the TOC title based on the detected language
    let tocTitleText = tocTitles[docLang] || tocTitles["default"];

    // Iterate through all TOC placeholders
    tocPlaceholders.forEach(tocPlaceholder => {
        // Find the main article container
        const articleContainer = document.querySelector(".gh-content") || document.querySelector(".l-post-content");
        if (!articleContainer) return;

        // Select all headings (h2, h3, h4) and exclude those inside `.m-tags`
        const headings = [...articleContainer.querySelectorAll("h2, h3, h4")].filter(h => !h.closest(".m-tags"));
        if (headings.length === 0) return;

        // Create the TOC container
        const containerElement = document.createElement("div");
        containerElement.className = "gh-toc-container";

        // Create the TOC title
        const titleElement = document.createElement("h2");
        titleElement.className = "gh-toc-title";
        titleElement.textContent = tocTitleText;
        containerElement.appendChild(titleElement);

        // Create the main TOC list
        const tocList = document.createElement("ul");
        tocList.className = "gh-toc";
        containerElement.appendChild(tocList);

        // Initialize variables for managing the TOC structure
        let lastLevel = 2;
        let levelMap = { 2: tocList };
        let currentList = tocList;

        // Process headings and build TOC structure
        headings.forEach(heading => {
            const level = parseInt(heading.tagName.substring(1));

            if (!heading.id) {
                heading.id = heading.textContent.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "");
            }

            const listItem = document.createElement("li");
            const link = document.createElement("a");
            link.textContent = heading.textContent;
            link.href = `#${heading.id}`;
            listItem.appendChild(link);

            if (level > lastLevel) {
                const nestedList = document.createElement("ul");
                levelMap[lastLevel].lastElementChild.appendChild(nestedList);
                levelMap[level] = nestedList;
                currentList = nestedList;
            } else if (level < lastLevel) {
                currentList = levelMap[level] || tocList;
            }

            currentList.appendChild(listItem);
            levelMap[level] = currentList;
            lastLevel = level;
        });

        tocPlaceholder.appendChild(containerElement);
    });
});
</script>


Integration into a Post

Once the installation is complete, you can use the table of contents in any posts or pages. Simply insert the following HTML element at the desired position:

<!-- Table of contents -->
<div class="toc-placeholder"></div>

When the page loads, a table of contents will be rendered at this position. The heading will appear in the corresponding language, and clicking on an entry will jump directly to the relevant section in the post.


Creating a Custom Snippet

After inserting the HTML element, you can click on the "Save as Snippet" function and assign a name:

After that, you can easily add the block to any post with a click:


With this method, you can create a flexible, easily integrable, and automatically updating table of contents for Ghost. Have fun testing it out!