Here is a way to do Table of Contents (ToC)

After about 2 hours of fiddling with it and finding other peoples anwers, I got it to work (using jQuery).

Steps:

  1. Add script at the bottom of this post in code injection in {{ghost_foot}}
  2. Use a markdown card to create a ToC like so (copy-paste this):

.

# Table of contents
1. [Go to Header](#header-name)

EDIT (important): You can also do it inline, if you want to link to something in one of your other posts or cross-reference in your current post. You would just highlight any word and click the link icon (or press CTRL+K), then insert as described in the next paragraph…

Whenever you create a header, you give that header a header name like Header Name, which translates to a link of #header-name. I hope it makes sense with the naming scheme, else feel free to comment.

<script>
(function(document, history, location) {
  var HISTORY_SUPPORT = !!(history && history.pushState);

  var anchorScrolls = {
    ANCHOR_REGEX: /^#[^ ]+$/,
    OFFSET_HEIGHT_PX: 70,

    /**
     * Establish events, and fix initial scroll position if a hash is provided.
     */
    init: function() {
      this.scrollToCurrent();
      $(window).on('hashchange', $.proxy(this, 'scrollToCurrent'));
      $('body').on('click', 'a', $.proxy(this, 'delegateAnchors'));
    },

    /**
     * Return the offset amount to deduct from the normal scroll position.
     * Modify as appropriate to allow for dynamic calculations
     */
    getFixedOffset: function() {
      return this.OFFSET_HEIGHT_PX;
    },

    /**
     * If the provided href is an anchor which resolves to an element on the
     * page, scroll to it.
     * @param  {String} href
     * @return {Boolean} - Was the href an anchor.
     */
    scrollIfAnchor: function(href, pushToHistory) {
      var match, anchorOffset;

      if(!this.ANCHOR_REGEX.test(href)) {
        return false;
      }

      match = document.getElementById(href.slice(1));

      if(match) {
        anchorOffset = $(match).offset().top - this.getFixedOffset();
        $('html, body').animate({ scrollTop: anchorOffset});

        // Add the state to history as-per normal anchor links
        if(HISTORY_SUPPORT && pushToHistory) {
          history.pushState({}, document.title, location.pathname + href);
        }
      }

      return !!match;
    },
    
    /**
     * Attempt to scroll to the current location's hash.
     */
    scrollToCurrent: function(e) { 
      if(this.scrollIfAnchor(window.location.hash) && e) {
      	e.preventDefault();
      }
    },

    /**
     * If the click event's target was an anchor, fix the scroll position.
     */
    delegateAnchors: function(e) {
      var elem = e.target;

      if(this.scrollIfAnchor(elem.getAttribute('href'), true)) {
        e.preventDefault();
      }
    }
  };
	$(window).on( "load", $.proxy(anchorScrolls, 'init'));
	//$(document).ready($.proxy(anchorScrolls, 'init'));
})(window.document, window.history, window.location);
</script>
12 Likes

This is great. Thanks for sharing this

1 Like

I had this request for the longest time. It really works wonders and pretty smoothly.
Let me know if there is anything else you need to know about this ToC :slight_smile:

I found a bug, where you could not click on the subscribe button on the front page. Here is the updated script

<script>
(function(document, history, location) {
  var HISTORY_SUPPORT = !!(history && history.pushState);

  var anchorScrolls = {
    ANCHOR_REGEX: /^#[^ ]+$/,
    OFFSET_HEIGHT_PX: 130,

    /**
     * Establish events, and fix initial scroll position if a hash is provided.
     */
    init: function() {
      this.scrollToCurrent();
      $(window).on('hashchange', $.proxy(this, 'scrollToCurrent'));
      $('body').on('click', 'a', $.proxy(this, 'delegateAnchors'));
    },

    /**
     * Return the offset amount to deduct from the normal scroll position.
     * Modify as appropriate to allow for dynamic calculations
     */
    getFixedOffset: function() {
      return this.OFFSET_HEIGHT_PX;
    },

    /**
     * If the provided href is an anchor which resolves to an element on the
     * page, scroll to it.
     * @param  {String} href
     * @return {Boolean} - Was the href an anchor.
     */
    scrollIfAnchor: function(href, pushToHistory) {
      var match, anchorOffset;

      if(!this.ANCHOR_REGEX.test(href)) {
        return false;
      }

      match = document.getElementById(href.slice(1));

      if(match) {
        anchorOffset = $(match).offset().top - this.getFixedOffset();
        $('html, body').animate({ scrollTop: anchorOffset});

        // Add the state to history as-per normal anchor links
        if(HISTORY_SUPPORT && pushToHistory) {
          history.pushState({}, document.title, location.pathname + href);
        }
      }

      return !!match;
    },
    
    /**
     * Attempt to scroll to the current location's hash.
     */
    scrollToCurrent: function(e) { 
      if(this.scrollIfAnchor(window.location.hash) && e) {
      	e.preventDefault();
      }
    },

    /**
     * If the click event's target was an anchor, fix the scroll position.
     */
    delegateAnchors: function(e) {
      var elem = e.target;
      if(elem.getAttribute('href') != '#subscribe'){
          if(this.scrollIfAnchor(elem.getAttribute('href'), true)) {
        		e.preventDefault();
      	  	}
      }
    }
  };
	$(window).on( "load", $.proxy(anchorScrolls, 'init'));
	//$(document).ready($.proxy(anchorScrolls, 'init'));
})(window.document, window.history, window.location);
</script>
1 Like

Hey,
Do I need to install JQuery for this to work?

Nope. Just follow the steps and you will be fine. If you want a preview of it, you can visit my website. Here is a post that you can try it out

Brilliant, thanks!!!
Just a small note that I didn’t know: caps matters :slight_smile:

It seems that the generated id is in fact “headername” rather than “header-name”.

The ID is generated by Ghost core, so you have to contact them or do a pull request on github yourself

Awesome thanks! Also if you use VSCode grab Markdown All in One then you can generate the ToC VS Code (ctrl + shift + p and type toc and Insert it.

1 Like

This is great. Thanks for sharing this

1 Like

This works great in general, however when I use the code inject for the footer it puts a TOC on the home page as well as the posts except for adding the card at the bottom of the page. I tried you second code that should eliminate the bottom card but it actually did nothing in my theme. No TOC at all. I haven’t figure out a way to get it to ignore the main landing page yet. If anyone does please let us know or if I can get it figured out I’ll post it.

I don’t want to edit the html of each post page as that can get out of hand quickly on a large site.

Sorry, I got confused on who’s code I was using. I actually used Add a dynamic Table of Contents to a Ghost blog

And I finally did get it to work correctly once I read it right and placed the code at the bottom of the post in a HTML card.

Hey all :wave:, we’ve recently put together a new tutorial to show you how to add a Table Of Contents to your Ghost site. Take a look, hope this helps:
https://ghost.org/tutorials/adding-a-table-of-contents/

PS. If you have any feedback let us know!

6 Likes

Very good tutorial indeed! Thanks for the insight.

1 Like

This is very cool. Thank you for working on this.

I noticed that the sticky table of contents shifts the text to the left and widens the text box, so there are more words per line. Is there a way to keep the text centered and the text box the same width? I think keeping the text in the center and having fewer words per line makes the content easier to read.

My testing with sticky TOC (compare with the Lyra Demo without TOC):

For non-sticky TOC, the h3 headers add an extra line in the TOC and only appear under the first h2:

How does one retain the current form of content being centered and occupying let’s say about 40% width, while still having the sticky TOC on the side?

@theodorechu @kairos This is all possible by adjusting the values in the CSS. The example provided in the tutorial is designed to get you started. Changing the values for the .post-full-content CSS from the ones in the tutorial (https://ghost.org/tutorials/adding-a-table-of-contents/#advanced-styling) is a good place to start :slight_smile:

OK, thanks @DavidDarnes. I looked at the advanced styling, and I noticed a problem. Here’s some feedback:

On Casper/Lyra, the max-width of .inner is 1040px, and .post-full-content is inside .inner, so the max-width of .post-full-content is also 1040px.

Suppose we want to preserve the width of .post-content at the default 700px.

The min-width of the sticky TOC according to the advanced styling tutorial is 260px. We probably don’t want to make this any smaller.

The sticky TOC is on the right side of .post-content, so we can center the .post-content column by adding another column on the left side of .post-content. We can also assign a min-width of 260px.

The width of the three columns together is 260px + 700px + 260px = 1220px. This is greater than the width allowed by .inner, so the columns are off-center.

The result is the following:

Do you have any suggestions on how to fix this?

Do you have any suggestions on how to fix the problem of the h3 headers not showing in the TOC?

@theodorechu are you using CSS float: right for the div containing the TOC?