Adding a knowledge base or documentation hub to your Shipixen template

Updated on

In this guide, we will show you how to add a knowledge base or documentation hub to your Shipixen template.

That will work & look similar to the documentation hub you are currently reading.

We'll go through the following steps:

  • Add CSS
  • Add required components for Table of Contents and Scroll Restoration
  • Add blog post Hub layout & register it
  • Add example articles

Add required CSS

The blog post hub layout needs a few CSS classes to look properly. Add the following CSS to your globals.css file:

css/globals.css
/* ... */

  .nav-link {
    @apply opacity-100 text-slate-900 dark:text-slate-200;
  }

+  .toc-link {
+    @apply text-sm;
+  }
+
+  .toc-active-link {
+    @apply text-sm text-primary-500 dark:text-primary-400;
+  }
+
+  .toc-list,
+  .toc-list ul,
+  ul.toc-list {
+    @apply flex flex-col gap-2;
+  }
+
+  .toc-separator {
+    @apply border-b border-gray-300 dark:border-neutral-600;
+  }
+
+  .toc-title {
+    @apply mt-6 text-sm font-semibold text-black dark:text-white;
+  }

/* ... */

Add required components

The documentation hub can automatically render a table of contents for your articles, as well as maintain scroll position.

To do that, we need to create the following files:

components/blog/useTocHeadObserver.ts
import { useEffect, useState, useRef } from 'react';

export function useTocHeadObserver() {
  const observer = useRef<null | IntersectionObserver>();
  const [activeId, setActiveId] = useState('');

  useEffect(() => {
    const handleObserver = (entries) => {
      for (let i = 0; i < entries.length; i++) {
        const entry = entries[i];
        if (entry?.isIntersecting) {
          setActiveId('#' + entry.target.id);
        }
      }
    };

    observer.current = new IntersectionObserver(handleObserver, {
      rootMargin: '0px 0px -98% 0px',
    });

    const elements = document.querySelectorAll('h1 ,h2 ,h3 ,h4 ,h5 ,h6');
    elements.forEach((elem) => {
      if (observer.current) {
        observer.current.observe(elem);
      }
    });

    return () => observer.current?.disconnect();
  }, []);

  return { activeId };
}
components/blog/Toc.tsx
'use client';

import { useTocHeadObserver } from '@/components/blog/useTocHeadObserver';
import { cn } from '@/lib/utils';
import { Toc } from '@shipixen/pliny/mdx-plugins/remark-toc-headings';

export interface TOCProps {
  toc: Toc;
  indentDepth?: number;
  fromHeading?: number;
  toHeading?: number;
  asDisclosure?: boolean;
  exclude?: string | string[];
  className?: string;
}

/**
 * Generates an table of contents
 * Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
 * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
 *
 * @param {TOCProps} {
 *   toc,
 *   indentDepth = 3,
 *   fromHeading = 1,
 *   toHeading = 6,
 *   asDisclosure = false,
 *   exclude = '',
 * }
 *
 */
const TOC = ({
  toc,
  indentDepth = 3,
  fromHeading = 1,
  toHeading = 6,
  asDisclosure = false,
  exclude = '',
  className,
}: TOCProps) => {
  const re = Array.isArray(exclude)
    ? new RegExp('^(' + exclude.join('|') + ')$', 'i')
    : new RegExp('^(' + exclude + ')$', 'i');

  const filteredToc = toc.filter(
    (heading) =>
      heading.depth >= fromHeading &&
      heading.depth <= toHeading &&
      !re.test(heading.value),
  );

  const { activeId } = useTocHeadObserver();

  const tocList = (
    <ul className={className}>
      {filteredToc.map((heading) => {
        return (
          <li
            key={heading.value}
            className={cn(
              `${heading.depth >= indentDepth ? 'ml-6' : ''}`,
              activeId === heading.url
                ? 'toc-active-link'
                : 'toc-link fancy-link fancy-link--no-underline',
            )}
          >
            <a href={heading.url}>{heading.value}</a>
          </li>
        );
      })}
    </ul>
  );

  return (
    <>
      {asDisclosure ? (
        <details open className={className}>
          <summary className="ml-6 pt-2 pb-2 text-xl font-bold">
            Table of Contents
          </summary>
          <div className="ml-6">{tocList}</div>
        </details>
      ) : (
        tocList
      )}
    </>
  );
};

export { TOC };
lib/useRestoreScroll.ts
'use client';

import { useEffect } from 'react';

// Check the scroll position of the passed element id and restore it if it exists.
// Save the position to local storage on scroll.
export const useRestoreScrollPosition = ({
  elementId,
}: {
  elementId: string;
}) => {
  useEffect(() => {
    const element = document.getElementById(elementId);
    if (!element) return;

    const scrollPosition = localStorage.getItem(elementId);
    if (!scrollPosition) return;

    setTimeout(() => {
      element.scrollTop = parseInt(scrollPosition);
    }, 1);
  }, [elementId]);

  useEffect(() => {
    const element = document.getElementById(elementId);
    if (!element) return;

    const onScroll = () => {
      localStorage.setItem(elementId, element.scrollTop.toString());
    };

    element.addEventListener('scroll', onScroll);

    return () => {
      element.removeEventListener('scroll', onScroll);
    };
  }, [elementId]);

  return null;
};
lib/ScrollRestoration.ts
'use client';
import { useRestoreScrollPosition } from '@/lib/useRestoreScroll';

export const ScrollRestoration = ({ elementId }: { elementId: string }) => {
  useRestoreScrollPosition({ elementId });
  return null;
};

We'll use these components in the next step, when we create the blog post hub layout component.

Create new layout & register it

Layouts are components that wrap blog posts. The Shipixen blog comes with a few layouts out of the box, but you can create your own.

The documentation hub at its core is a custom layout. We'll create a new layout component and register it.

layouts/PostHub.tsx
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
import { CoreContent } from '@shipixen/pliny/utils/contentlayer';
import type { Blog, Authors } from 'shipixen-contentlayer/generated';
import Link from '@/components/shared/Link';
import PageTitle from '@/components/shared/PageTitle';
import Tag from '@/components/blog/Tag';
import { siteConfig } from '@/data/config/site.settings';
import ScrollTop from '@/components/shared/ScrollTop';
import ActiveLink from '@/components/shared/ActiveLink';
import { TOC } from '@/components/blog/Toc';
import { ScrollRestoration } from '@/lib/ScrollRestoration';
import Header from '@/components/shared/Header';

const postDateTemplate: Intl.DateTimeFormatOptions = {
  weekday: 'long',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
};

interface LayoutProps {
  className?: string;
  content: CoreContent<Blog>;
  authorDetails: CoreContent<Authors>[];
  next?: { path: string; title: string };
  prev?: { path: string; title: string };
  children: ReactNode;
}

export default function PostHubLayout({
  className,
  content,
  authorDetails,
  next,
  prev,
  children,
}: LayoutProps) {
  const { date, lastmod, title, tags, toc } = content;

  return (
    <div className="w-full flex flex-col items-center">
      <Header />

      <div
        className={cn(
          'w-full fancy-overlay fancy-overlay--muted flex flex-col flex-wrap lg:flex-row justify-center',
          className,
        )}
      >
        <aside
          className="lg:sticky max-h-screen top-0 p-6 w-full lg:w-[14rem] 2xl:w-full xl:max-w-[12rem] 2xl:max-w-[16rem] overflow-auto pb-12"
          id="navigation"
        >
          <ul className="toc-list">
            <li>
              <ActiveLink
                href="/docs/overview"
                className="toc-link fancy-link fancy-link--no-underline"
                activeClassName="toc-active-link"
              >
                <span>Overview</span>
              </ActiveLink>
            </li>

            <li className="toc-separator"></li>

            <li className="toc-title">
              <h3>Docs</h3>
            </li>

            <li>
              <ActiveLink
                href="/docs/get-started"
                className="toc-link fancy-link fancy-link--no-underline"
                activeClassName="toc-active-link"
              >
                <span>Getting Started</span>
              </ActiveLink>
            </li>
          </ul>
        </aside>

        <article className="w-full lg:max-w-3xl !p-0 !m-0 shadow-lg">
          <div className="bg-white dark:bg-slate-900 lg:px-10 lg:py-4 divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
            <header className="px-6 pt-6 pb-6 lg:px-0" id="main">
              <div className="space-y-1">
                <div>
                  <PageTitle>{title}</PageTitle>
                </div>

                <dl className="space-y-10">
                  {lastmod ? (
                    <div>
                      <dt className="sr-only">Updated on</dt>
                      <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
                        <time dateTime={lastmod}>
                          <span className="font-light">Last updated</span>{' '}
                          {new Date(lastmod).toLocaleDateString(
                            siteConfig.locale,
                            postDateTemplate,
                          )}
                        </time>
                      </dd>
                    </div>
                  ) : (
                    <div>
                      <dt className="sr-only">Published on</dt>
                      <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
                        <time dateTime={date}>
                          <span className="font-light">Published</span>{' '}
                          {new Date(date).toLocaleDateString(
                            siteConfig.locale,
                            postDateTemplate,
                          )}
                        </time>
                      </dd>
                    </div>
                  )}
                </dl>
              </div>
            </header>

            <div className="prose max-w-none px-6 pb-8 pt-10 lg:px-0 dark:prose-invert overflow-hidden">
              {children}
            </div>
          </div>

          <footer className="p-6">
            <div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
              {tags && (
                <div className="py-4 xl:py-8">
                  <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                    Tags
                  </h2>
                  <div className="flex flex-wrap">
                    {tags.map((tag) => (
                      <Tag key={tag} text={tag} />
                    ))}
                  </div>
                </div>
              )}
              {(next || prev) && (
                <div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
                  {prev && prev.path && (
                    <div>
                      <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                        Previous Article
                      </h2>
                      <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
                        <Link href={`/${prev.path}`}>{prev.title}</Link>
                      </div>
                    </div>
                  )}
                  {next && next.path && (
                    <div>
                      <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                        Next Article
                      </h2>
                      <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
                        <Link href={`/${next.path}`}>{next.title}</Link>
                      </div>
                    </div>
                  )}
                </div>
              )}

              <div className="flex pt-4 xl:pt-8">
                <Link
                  href={siteConfig.allArticlesPath}
                  className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
                  aria-label="More articles"
                >
                  More articles &rarr;
                </Link>
              </div>
            </div>
          </footer>
        </article>

        <aside className="relative xl:sticky top-0 p-6 w-full xl:max-w-[16rem] xl:max-h-screen flex-col items-center overflow-auto pb-12 lg:max-w-xl">
          <h2 className="font-semibold text-sm">Jump to section</h2>
          <TOC toc={toc} className={cn('toc-list wide-container !p-0 mt-4')} />
        </aside>

        <ScrollRestoration elementId="navigation" />
        <ScrollTop />
      </div>
    </div>
  );
}

Now, register the layout in app/[...slug].tsx:

app/[...slug].tsx
import 'css/prism.css';
import 'katex/dist/katex.css';

import PageTitle from '@/components/shared/PageTitle';
import { components } from '@/components/MDXComponents';
import { MDXLayoutRenderer } from '@shipixen/pliny/mdx-components';
import {
  sortPosts,
  coreContent,
  allCoreContent,
} from '@shipixen/pliny/utils/contentlayer';
import { allBlogs, allAuthors } from 'shipixen-contentlayer/generated';
import type { Authors, Blog } from 'shipixen-contentlayer/generated';
import PostSimple from '@/layouts/PostSimple';
import PostLayout from '@/layouts/PostLayout';
import PostBanner from '@/layouts/PostBanner';
+import PostHub from '@/layouts/PostHub';

import { Metadata } from 'next';
import { siteConfig } from '@/data/config/site.settings';

const BLOG_URL = siteConfig.blogPath ? `/${siteConfig.blogPath}` : '';

const defaultLayout = 'PostLayout';
const layouts = {
  PostSimple,
  PostLayout,
  PostBanner,
+ PostHub,
};

// ...

To note, the post hub layout component contains a few hardcoded links to the documentation articles. You can change those to your own articles and organize them as you see fit:

// ...

<ul className="toc-list">
  <li>
    <ActiveLink
      href="/docs/overview"
      className="toc-link fancy-link fancy-link--no-underline"
      activeClassName="toc-active-link"
    >
      <span>Overview</span>
    </ActiveLink>
  </li>

  <li className="toc-separator"></li>

  <li className="toc-title">
    <h3>Docs</h3>
  </li>

  <li>
    <ActiveLink
      href="/docs/get-started"
      className="toc-link fancy-link fancy-link--no-underline"
      activeClassName="toc-active-link"
    >
      <span>Getting Started</span>
    </ActiveLink>
  </li>
</ul>

// ...

We'll proceed to create those articles in the next step.

Add example articles

This part is similar to adding any other blog post in Shipixen.
You can create a Markdown file under data or in a subfolder of data e.g. data/docs/get-started.mdx.

In this example, we'll put the articles under data/docs/.
Create the following files:

data/docs/get-started.mdx
---
title: 'Get started'
date: '2023-12-01'
tags:
  - guide
  - documentation
summary: 'Get started with the documentation.'
layout: PostHub
---

Get started easily with the documentation.

## Summary

This shows how to get started with the documentation.
data/docs/overview.mdx
---
title: 'Overview'
date: '2023-12-03'
lastmod: '2023-12-03'
tags:
  - guide
  - documentation
summary: 'An overview of the documentation'
layout: PostHub
---

Overview of the documentation.

## Summary

This is the summary of the documentation. [Get started here](/docs/get-started).

Heads up! Remember to add the PostHub layout for each of those articles.

Now, if you navigate to e.g. /docs/get-started, you should see the documentation hub with the articles you just created:

Documentation hub

Using the documentation hub as the homepage

It is possible to use the documentation hub as the homepage of your website.

To do that, you need to change the root page.tsx as follows:

app/page.tsx
import { CoreContent } from '@shipixen/pliny/utils/contentlayer';
import type { Blog } from 'shipixen-contentlayer/generated';
import PostHubLayout from '@/layouts/PostHub';

export default function Home() {
  return (
    <div className="flex flex-col w-full items-center fancy-overlay">
      <PostHubLayout
        content={
          {
            date: '2023-09-01',
            lastmod: '2023-09-01',
            title: 'Welcome to the docs site!',
            tags: ['docs'],
            toc: [],
          } as CoreContent<Blog>
        }
        authorDetails={[]}
        className="py-4"
      >
        <p>
          This is the home page. You can edit this page in{' '}
          <code>app/home.tsx</code>.
        </p>
      </PostHubLayout>
    </div>
  );
}

Troubleshooting

Type 'string' is not assignable to type 'Toc'

Make sure you have the correct type for the toc field in contentlayer.config.ts (json),

contentlayer.config.ts
-  toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
+  toc: { type: 'json', resolve: (doc) => extractTocHeadings(doc.body.raw) },