Builder Book

  1. Introduction
  2. App structure. Next.js. HOC. Material-UI. Server-side rendering. Styles.
  3. Server. Database. Session. Header and MenuDrop components.
  4. Authentication HOC. Promise. Async/await. Static method for User model. Google OAuth.
  5. Testing with Jest. Debugging with Winston. Transactional emails. In-app notifications.
  6. Book and Chapter models. Internal API. Render chapter.
  7. Github integration. Admin dashboard. Testing Admin UX and Github integration.
  8. Table of Contents. Highlight for section. Hide Header. Mobile browser.
  9. BuyButton component. Buy book logic. ReadChapter page. Checkout flow. MyBooks page. Mailchimp API. Deploy app.

Chapter 7: Table of Contents. Highlight for section. Hide Header. Mobile browser.

We keep the book up-to-date with the latest frameworks and packages.


In Chapter 7, you'll start with the codebase in the 7-start folder of our builderbook repo and end up with the codebase in the 7-end folder. We'll cover the following topics in this chapter:

  • Table of Contents
    - Sections
    - Sidebar
    - Toggle TOC

  • Highlight for section

  • Hide Header

  • Mobile browser


In Chapter 5, you built the ReadChapter page, which displays the content of one chapter. In this chapter, we will make multiple improvements to this page.

For example, a user should be able to navigate between chapters and between sections within one chapter. To achieve that, we need to introduce a Table of Contents (TOC). The TOC should contain:

  • hyperlinked sections within each chapter
  • hyperlinked titles of all chapters

Here's an example of what the TOC would look like: Builder Book

link Table of Contents

On a high level, we will add the TOC to our ReadChapter page in two main steps. We will discuss and write:

  • renderSections() function that returns a list of hyperlinked sections within one chapter
  • renderSidebar() function that returns a list of hyperlinked titles for all chapters and includes renderSections() under each chapter's title

We will add the renderSidebar() function to the ReadChapter component's render() function and then test out the TOC.

link Sections

In this subsection, we define the renderSections() function. This function returns a list of hyperlinked sections for one chapter.

Recall how we defined the sections parameter in our Chapter model. Open server/models/Chapter.js and look at the schema:

sections: [
    {
        text: String,
        level: Number,
        escapedText: String,
    },
],

Sections is an array of objects, and each object has text, level, and escapedText. Where do these three parameters come from? In the same server/models/Chapter.js file, find how we generate the sections array:

const sections = getSections(content);

Definition of getSections():

function getSections(content) {
    const renderer = new marked.Renderer();

    const sections = [];

    renderer.heading = (text, level) => {
        if (level !== 2) {
            return;
        }

        const escapedText = text
            .trim()
            .toLowerCase()
            .replace(/[^\w]+/g, '-');

        sections.push({ text, level, escapedText });
    };

    marked.setOptions({
        renderer,
    });

    marked(he.decode(content));

    return sections;
}

Marked parses markdown content and finds headings with level equal to 2 (any heading that has ##). For every heading, we push an object to the sections array: sections.push({ text, level, escapedText })

In other words, if your markdown content has: ## Why this book?

Then getSections(content) will return this array:

[
    {
        "text": "Why this book?",
        "level": 2,
        "escapedText": "why-this-book-"
    },
]

We will use text as text inside <a>{text}</a> and use escapedText for href. When a user clicks on a hyperlinked section inside the TOC, we want the page to scroll to the beginning of that section. In fact, when we wrote our markdownToHtml() function in Chapter 6, we defined the <h2> heading as follows:

if (level === 2) {
    return `<h${level} class="chapter-section" style="color: #222; font-weight: 400;">
        <a
            class="section-anchor"
            name="${escapedText}"
            href="#${escapedText}"
            style="color: #222;"
        > 
            <i class="material-icons" style="vertical-align: middle; opacity: 0.5; cursor: pointer;">link</i>
        </a>
        ${text}
    </h${level}>`;
}

Let's say we have the heading ## Why this book? in our markdown content. When user a clicks on the link icon next to that heading, two things will happen:

  • the URL in the browser address bar gets #why-this-book- appended to it
  • the page scrolls to the beginning of that section because <a> has the name attribute

We want to get the exact same behaviour when a user clicks on the hyperlinked section text inside our TOC. Thus we get:

<a href={`#${s.escapedText}`}>
    {s.text}
</a>

Keep in mind that sections is an array. Thus, you should use JavaScript's method .map():

<ul>
    {sections.map(s => (
        <li key={s.escapedText} style={{ paddingTop: '10px' }}>
            <a href={`#${s.escapedText}`}>
                {s.text}
            </a>
        </li>
    ))}
</ul>

You already used this method earlier in this book. Check out books.map() in pages/admin/index.js.

At this point, the main part of our renderSection() function is actually done.

Open pages/public/read-chapter.js. Remember that we send a chapter object to a page with:

const chapter = await getChapterDetail()

And then we set the initial state of our ReadChapter page component with:

this.state = {
    chapter,
    htmlContent,
};

Now let's use ES6 object destructuring to define sections as this.state.chapter.sections:

const { sections } = this.state.chapter

Also, let's return null if the section array does not exist or has zero objects:

if (!sections || !sections.length === 0) {
    return null;
}

Put together these two code snippets, plus return a list of hyperlinked sections, and you get:

renderSections() {
    const { sections } = this.state.chapter;

    if (!sections || !sections.length === 0) {
        return null;
    }

    return (
        <ul>
            {sections.map(s => (
                <li key={s.escapedText} style={{ paddingTop: '10px' }}>
                    <a href={`#${s.escapedText}`}>
                        {s.text}
                    </a>
                </li>
            ))}
        </ul>
    );
}

Good job if you got the same result. We'll add this to our read-chapter.js page at the end of the next subsection.

link Sidebar

In this subsection, we discuss the renderSidebar() function that returns a list of hyperlinked titles for all chapters. We will then add our renderSection() function from above to add a list of all sections under each chapter title.

Similar to how we defined renderSections(), we will use list, list item, and anchor elements together with the JavaScript method .map() for our renderSidebar() function:

renderSidebar() {
    return (
        <div>
            <p>{book.name}</p>
            <ol>
                {chapters.map((ch, i) => (
                    <li key={ch._id} role="presentation">
                        <Link
                            prefetch
                            as={`/books/${book.slug}/${ch.slug}`}
                            href={`/public/read-chapter?bookSlug=${book.slug}&chapterSlug=${ch.slug}`}
                        >
                            <a>{ch.title}</a>
                        </Link>
                    </li>
                ))}
            </ol>
        </div>
    );
}

The only differences are:

  • instead of an unordered list <ul>, we use an ordered list <ol>
  • we wrap the anchor <a> with Next.js's <Link>, so we can take advantage of the prefetch feature in production
  • we also display the book name on the top of the TOC with <p>{book.name}</p>

Time to add renderSections() to renderSidebar() to our code. In doing so, we should think about creating good UX. Do we want every chapter to have a list of sections on the TOC? That might be an overwhelming amount of information.

It's sufficient to show sections for only the chapter that is currently rendered on the ReadChapter page. In other words, if the chapter id from the page's state (chapter._id) equals the chapter id from the list (ch._id), then we display the list of sections. Otherwise, we return null:

{chapter._id === ch._id ? this.renderSections() : null}

Add this line of code right after the hyperlinked title of the chapter in the ReadChapter page:

renderSidebar() {
    return (
        <div>
            <p>{book.name}</p>
            <ol>
                {chapters.map((ch, i) => (
                    <li key={ch._id} role="presentation">
                        <Link
                            prefetch
                            as={`/books/${book.slug}/${ch.slug}`}
                            href={`/public/read-chapter?bookSlug=${book.slug}&chapterSlug=${ch.slug}`}
                        >
                            <a>{ch.title}</a>
                        </Link>
                        {chapter._id === ch._id ? this.renderSections() : null}
                    </li>
                ))}
            </ol>
        </div>
    );
}

Alright, the main part of our renderSidebar() function is done. However, if you look at the code above, you may notice that we have not defined the following three variables:

  1. chapter (we used it in chapter._id)
  2. book (used in book.name)
  3. chapters (used in chapters.map())

Let's discuss in more detail.

1) Defining chapter is easy, since we initiate state with chapter in it. Thus:

const { chapter } = this.state;

2) To understand how to define book, we should look into how we define chapter. In pages/public/read-chapter.js, find the line:

const chapter = await getChapterDetail()

The API method getChapterDetail() sends a request to our Express route router.get('/get-chapter-detail'). Open server/api/public.js and find this Express route:

router.get('/get-chapter-detail', async (req, res) => {
    try {
        const { bookSlug, chapterSlug } = req.query;
        const chapter = await Chapter.getBySlug({
        bookSlug,
        chapterSlug,
        });
        res.json(chapter);
    } catch (err) {
        res.json({ error: err.message || err.toString() });
    }
});

Ok, now we remember that chapter gets returned by the Chapter.getBySlug static method. Let's look back at that method. Open server/models/Chapter.js and find getBySlug() static method:

static async getBySlug({ bookSlug, chapterSlug }) {
    const book = await Book.getBySlug({ slug: bookSlug, userId });

    if (!book) {
        throw new Error('Book not found');
    }

    const chapter = await this.findOne({ bookId: book._id, slug: chapterSlug });

    if (!chapter) {
        throw new Error('Chapter not found');
    }

    const chapterObj = chapter.toObject();
    chapterObj.book = book;

    return chapterObj;
}

Aha! So the book object is part of the chapter object! Because chapterObj.book = book; and const chapter = await getChapterDetail(), then:

const book = chapter.book

Or with ES6 object destructuring:

const { book } = chapter

To continue reading, buy this book.

We keep the book up-to-date with the latest frameworks and packages.


format_list_bulleted
help_outline