เพิ่ม table of content สำหรับ Ghost CMS กัน

Programming May 3, 2024

เนื่องจากเราเองคิดว่าคนอ่านอาจจะอยากได้ตัว url ที่เป็นหัวข้อข้างในบทความ ที่มันเป็นลิ้ง # แล้วตามด้วยหัวข้อ

แต่เราก็พยายามหาหลายวิธี จนมาเจอวิธีจากทาง official ของ Ghost CMS เอง

แต่ด้วยความที่เราใช้ theme อื่น ที่ไม่ใช่แบบ default คือธีม Casper อย่าง liebling อาจจะมีความ adapt เพิ่มนิดนึง บล็อกนี้ก็เลยเกิดขึ้นมาเพื่อบันทึกว่าเราทำอะไรไปบ้าง เผื่อคนอื่น ๆ อยากจะเอามาใช้ด้วย ซึ่งเราจะแก้โดยใช้การ coding เนอะ

ทำความรู้จัก Tocbot กันก๊อนนนน

Tocbot เป็น library ตัวนึง ที่ช่วย generate table of content ในบล็อก post ของเรา ตาม structure ของ HTML document สำหรับ website หรือพวก markdown page ต่าง ๆ

table of content คือเป็นตัวบอกว่า บล็อกนี้มีหัวข้ออะไรบ้าง อย่างบล็อกนี้ก็จะมี

  • ทำความรู้จัก Tocbot กันก๊อนนนน
  • เอา tocbot มาใช้งานกัน
  • Reference

ซึ่งคนอ่านจะเห็นอยู่ด้านซ้ายถ้าอ่านจากบนคอม และบนมือถือจะอยู่ด้านบนนะ

ก่อนอื่น เรามาทำการติดตั้งกันก่อน ด้วย npm เพื่ออัพเดตตัว package.json ของเรา

npm install --save tocbot

แต่ในความจริงการ setup อื่น ๆ อยู่ที่ไฟล์ตัว theme ของ css ที่เราใช้เนอะ

เอา tocbot มาใช้งานกัน

ไปที่ตัวโปรเจกต์ของธีมของเรา มี 2 ไฟล์ที่ต้องใส่เพิ่มด้วยกัน

default.hbs

เพิ่ม css ของตัว tocbot กัน ไว้ที่ส่วน <head> และใส่ก่อน {{ghost_head}}

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.25.0/tocbot.css">

ใส่ style เพิ่มเติมลงไป ใส่ใน <style> ที่มีอยู่แล้ว มันอยู่ใต้ {{ghost_head}}

<style>

      .gh-content {
        position: relative;
      }

      .gh-toc > .toc-list {
        position: relative;
        font-size: medium;
      }

      .toc-list {
        overflow: hidden;
        list-style: none;
      }

      @media (min-width: 1300px) {
        .gh-sidebar {
            position: absolute; 
            top: 0;
            bottom: 0;
            margin-top: 4vmin;
            left: -500px;
            width: 400px;
            grid-column-start: auto;
        }

        .gh-toc {
            position: sticky; /* On larger screens, TOC will stay in the same spot on the page */
            top: 4vmin;
        }
      }

      .gh-toc .is-active-link::before {
        background-color: var(--ghost-accent-color); /* Defines TOC   accent color based on Accent color set in Ghost Admin */
      }

      a.toc-link {
        text-decoration: none;
        font-size: medium;
      }
</style>

เรามีเพิ่มจาก tutorial ที่เราปรับไปทีหลัง คือ

  • ปรับตำแหน่งการแสดงให้มันอยู่ที่ว่างทางซ้าย และขนาดไม่ให้มันล้นไปส่วนเนื้อหา
  • ปรับขนาดตัวหนังสือให้มันพอดี และอยากให้ตัว link ของ table of content ไม่มีขีด

เพิ่มส่วน script ลงไป ใส่ก่อน {{ghost_foot}}

<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.25.0/tocbot.min.js"></script>

จากนั้น init ตัว tocbot ขึ้นมา

{{! Initialize Tocbot after you load the script }}
<script>
    tocbot.init({
        // Where to render the table of contents.
        tocSelector: '.gh-toc',
        // Where to grab the headings to build the table of contents.
        contentSelector: '.gh-content',
        // Which headings to grab inside of the contentSelector element.
        headingSelector: 'h1, h2',
        // Ensure correct positioning
        hasInnerContainers: true,
    });
</script>
  • tocSelector คือ element ที่เราจะแสดงในตัว table of content ในที่นี้เป็น gh-toc
  • contentSelector คือ element ที่เป็น content ของบล็อกของเรา ในที่นี้เป็น gh-content
  • headingSelector ส่วน header ระดับไหนที่เราต้องการจะแสดงบน table of content ในที่นี้เราใช้แค่ h2 แหละ เพราะใช้ h3 ด้วยแล้วบางบล็อกมันยาว ดูจะล้น ๆ ไปนิดนึง

ภาพรวม default.hbs คร่าว ๆ

<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
{{!-- Document Settings --}}
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
{{!-- Base Meta --}}
<title>{{meta_title}}</title>
<meta name="HandheldFriendly" content="True" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{!-- Preload assets --}}
<link rel="preload" href="{{asset "css/app.css"}}" as="style" />
<link rel="preload" href="{{asset "js/manifest.js"}}" as="script" />
<link rel="preload" href="{{asset "js/vendor.js"}}" as="script" />
<link rel="preload" href="{{asset "js/app.js"}}" as="script" />
{{!-- This #block helper will try to preload page-specific assets --}}
{{{block "preload"}}}
{{!-- Styles & Scripts --}}
<style>
/* These font-faces are here to make fonts work if the Ghost instance is installed in a subdirectory */
/* source-sans-pro-regular */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('SourceSansPro-Regular'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-regular.woff2'}}") format('woff2'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-regular.woff'}}") format('woff');
}
/* source-sans-pro-600 */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: local('SourceSansPro-SemiBold'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-600.woff2'}}") format('woff2'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-600.woff'}}") format('woff');
}
/* source-sans-pro-700 */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('SourceSansPro-Bold'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-700.woff2'}}") format('woff2'),
url("{{asset 'fonts/source-sans-pro/latin/source-sans-pro-700.woff'}}") format('woff');
}
/* iconmoon */
@font-face {
font-family: 'icomoon';
font-weight: normal;
font-style: normal;
font-display: swap;
src: url("{{asset 'fonts/icomoon/icomoon.eot?101fc3'}}");
src: url("{{asset 'fonts/icomoon/icomoon.eot?101fc3#iefix'}}") format('embedded-opentype'),
url("{{asset 'fonts/icomoon/icomoon.ttf?101fc3'}}") format('truetype'),
url("{{asset 'fonts/icomoon/icomoon.woff?101fc3'}}") format('woff'),
url("{{asset 'fonts/icomoon/icomoon.svg?101fc3#icomoon'}}") format('svg');
}
</style>
<link rel="stylesheet" type="text/css" href="{{asset "css/app.css"}}" media="screen" />
{{!-- TOC styles --}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.25.0/tocbot.css">
{{!-- This #block helper will inject a stylesheet for a specific page --}}
{{{block "styles"}}}
{{!-- This #block helper will pull data from the hero partial
to inject styles of the hero image to make it responsive --}}
{{{block "herobackground"}}}
{{!-- This tag outputs SEO meta+structured data and other important settings --}}
{{ghost_head}}
{{!-- This style overrides the accent color to match the one from the Admin --}}
<style>
:root {
--primary-subtle-color: var(--ghost-accent-color) !important;
.gh-content {
position: relative;
}
.gh-toc > .toc-list {
position: relative;
font-size: medium;
}
.toc-list {
overflow: hidden;
list-style: none;
}
@media (min-width: 1300px) {
.gh-sidebar {
position: absolute;
top: 0;
bottom: 0;
margin-top: 4vmin;
left: -500px;
width: 400px;
grid-column-start: auto;
}
.gh-toc {
position: sticky; /* On larger screens, TOC will stay in the same spot on the page */
top: 4vmin;
}
}
.gh-toc .is-active-link::before {
background-color: var(--ghost-accent-color); /* Defines TOC accent color based on Accent color set in Ghost Admin */
}
a.toc-link {
text-decoration: none;
font-size: medium;
}
</style>
{{!-- These variables are used to make the search form work --}}
<script>
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat
const ghostHost = "{{@site.url}}"
// @license-end
</script>
{{#if @custom.enable_native_search}}
<script>
const nativeSearchEnabled = true
</script>
{{/if}}
{{#if @custom.search_api_key}}
<script>
const ghostSearchApiKey = "{{@custom.search_api_key}}"
</script>
{{/if}}
{{!-- This variable disbale the fade animation when it's enabled --}}
{{#if @custom.disable_fade_animation}}
<style>
:root {
--show-fade-animation: 0;
}
</style>
{{/if}}
{{!-- This script sets the correct theme mode (light or dark) --}}
<script>
if (typeof Storage !== 'undefined') {
const currentSavedTheme = localStorage.getItem('theme')
if (currentSavedTheme && currentSavedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
} else {
document.documentElement.setAttribute('data-theme', 'light')
}
}
</script>
</head>
<body class="{{body_class}}">
{{!-- All the main content gets inserted here, index.hbs, post.hbs, etc --}}
{{{body}}}
{{!-- Search form --}}
{{^if @custom.enable_native_search}}
{{> search}}
{{/if}}
{{!-- The footer --}}
{{> footer}}
{{!-- Common scripts shared between pages --}}
<script defer src="{{asset "js/manifest.js"}}"></script>
<script defer src="{{asset "js/vendor.js"}}"></script>
<script defer src="{{asset "js/app.js"}}"></script>
{{!-- Tocbot script --}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.25.0/tocbot.min.js"></script>
{{! Initialize Tocbot after you load the script }}
<script>
tocbot.init({
// Where to render the table of contents.
tocSelector: '.gh-toc',
// Where to grab the headings to build the table of contents.
contentSelector: '.gh-content',
// Which headings to grab inside of the contentSelector element.
headingSelector: 'h1, h2',
// Ensure correct positioning
hasInnerContainers: true,
});
</script>
{{!-- The #block helper will pull in data from the #contentFor other template files --}}
{{{block "scripts"}}}
{{!-- Ghost outputs important scripts and data with this tag --}}
{{ghost_foot}}
</body>
</html>
view raw default.hbs hosted with ❤ by GitHub

.

post.hbs

อันนี้ใส่แค่ element ของ table of content ที่เราจะแสดง ซึ่งจะต้องไว้ก่อนส่วน {{content}}

<aside class="gh-sidebar"><div class="gh-toc"></div></aside> {{! The TOC will be inserted here }}

ลอง deploy ขึ้นไป

cd src
npm run deploy

เมื่อเรา deploy ตัวธีมขึ้นไป เราไม่เห็น table of content พยายามแก้ใน developer tool แล้วมันก็ไม่มาสักที

สุดท้ายแก้โดยเราเพิ่ม element ที่ชื่อว่า gh-content เนื่องจากไม่มีอันนี้ในตัวไฟล์นี้ ใน tutorial เขาบอกว่ามันมีในธีม Casper เนาะ เราเลยใส่ครอบตัว {{content}} ไปแบบนี้

<div class="gh-content">
    {{content}}
</div>

จากนั้นลอง deploy อีกรอบ มาล่ะ มาแล้ว เย้ ๆ แล้วก็ปรับแต่งให้สวยงามตามต้องการ

ภาพรวม post.hbs คร่าว ๆ

{{!--
This template is used for the post page.
--}}
{{!-- This block preloads specific assets for the post page --}}
{{#contentFor "preload"}}
<link rel="preload" href="{{asset "css/post.css"}}" as="style" />
<link rel="preload" href="{{asset "js/post.js"}}" as="script" />
{{/contentFor}}
{{!-- This block loads specific styles for the post page --}}
{{#contentFor "styles"}}
<link rel="stylesheet" type="text/css" href="{{asset "css/post.css"}}" media="screen" />
{{/contentFor}}
{{!-- The tag below means: insert everything in this file
into the {body} of the default.hbs template --}}
{{!< default}}
{{!-- Special header.hbs partial to generate the <header> tag --}}
{{#post}}
{{> header background=feature_image}}
{{/post}}
<main class="main-wrap">
{{#post}}
{{!-- Inject styles of the hero image to make it responsive --}}
{{> hero background=feature_image}}
</section>
{{#if feature_image}}
{{#if feature_image_caption}}
<div class="l-wrapper in-caption">
<p class="m-small-text align-center">
{{feature_image_caption}}
</p>
</div>
{{/if}}
{{/if}}
{{/post}}
<article>
<div class="l-content in-post">
{{!-- Everything inside the #post tags pulls data from the post --}}
{{#post}}
<div class="l-wrapper in-post {{#unless feature_image}}no-image{{/unless}} js-animation-wrapper" data-animate="fade-up">
<div
class="l-post-content js-progress-content">
<header class="m-heading">
<h1 class="m-heading__title in-post">{{title}}</h1>
<div class="m-heading__meta">
{{#if primary_tag}}
<a href="{{primary_tag.url}}" class="m-heading__meta__tag">{{primary_tag.name}}</a>
<span class="m-heading__meta__divider" aria-hidden="true">&bull;</span>
{{/if}}
<span class="m-heading__meta__time">{{date published_at}}</span>
</div>
</header>
<div class="pos-relative js-post-content">
<aside class="gh-sidebar">
<p>On This Page</p>
<div class="gh-toc"></div>
</aside> {{! The TOC will be inserted here }}
<div class="m-share">
<div class="m-share__content js-sticky">
<a href="https://www.facebook.com/sharer/sharer.php?u={{url absolute='true'}}"
class="m-icon-button filled in-share" target="_blank" rel="noopener" aria-label="Facebook">
<span class="icon-facebook" aria-hidden="true"></span>
</a>
<a href="https://twitter.com/intent/tweet?text={{encode title}}&url={{url absolute='true'}}"
class="m-icon-button filled in-share" target="_blank" rel="noopener" aria-label="Twitter">
<span class="icon-twitter" aria-hidden="true"></span>
</a>
<button class="m-icon-button filled in-share progress js-scrolltop" aria-label="{{t "Scroll to top"}}">
<span class="icon-arrow-top" aria-hidden="true"></span>
<svg aria-hidden="true">
<circle class="progress-ring__circle js-progress" fill="transparent" r="0" />
</svg>
</button>
</div>
</div>
<div class="gh-content">
{{content}}
</div>
{{!-- List of tags --}}
{{#if tags}}
<section class="m-tags in-post">
<h3 class="m-submenu-title">{{t "Tags"}}</h3>
<ul>
{{#foreach tags}}
<li>
<a href="{{url}}" title="{{name}}">{{name}}</a>
</li>
{{/foreach}}
</ul>
</section>
{{/if}}
</div>
</div>
</div>
{{!-- Email subscribe form at the bottom of the page --}}
{{#if @site.members_enabled}}
<section class="m-subscribe-section js-newsletter">
<div class="l-wrapper in-post">
<div class="m-subscribe-section__content">
<div class="m-subscribe-section__text">
<h4 class="m-subscribe-section__title">{{t "Subscribe to our newsletter"}}</h4>
<p class="m-subscribe-section__description">
{{t "Get the latest posts delivered right to your inbox."}}
</p>
</div>
<div class="m-subscribe-section__form">
{{> "newsletter-form"}}
</div>
</div>
</div>
</section>
{{/if}}
<section class="m-author">
<div class="m-author__content">
<div class="m-author__picture">
<a href="{{primary_author.url}}" class="m-author-picture" aria-label="{{primary_author.name}}">
{{#if primary_author.profile_image}}
<div style="background-image: url({{primary_author.profile_image}});"></div>
{{else}}
<div style="background-image: url({{asset "images/default-avatar-square-small.jpg"}});"></div>
{{/if}}
</a>
</div>
<div class="m-author__info">
<h4 class="m-author__name">
<a href="{{primary_author.url}}">{{primary_author.name}}</a>
</h4>
{{#has author="count:>1"}}
<p class="m-small-text in-author-along-with">
{{authors separator=", " prefix=(t "Among with no break line") from="2"}}
</p>
{{/has}}
{{#if primary_author.bio}}
<p class="m-author__bio">{{primary_author.bio}}</p>
{{/if}}
<ul class="m-author-links">
{{#if primary_author.website}}
<li>
<a href="{{primary_author.website}}" target="_blank" rel="noopener" aria-label="{{t "Website"}}">
<span class="icon-globe" aria-hidden="true"></span>
</a>
</li>
{{/if}}
{{#if primary_author.facebook}}
<li>
<a href="https://facebook.com/{{primary_author.facebook}}" target="_blank" rel="noopener" aria-label="Facebook">
<span class="icon-facebook" aria-hidden="true"></span>
</a>
</li>
{{/if}}
{{#if primary_author.twitter}}
<li>
<a href="https://twitter.com/{{primary_author.twitter}}" target="_blank" rel="noopener" aria-label="Twitter">
<span class="icon-twitter" aria-hidden="true"></span>
</a>
</li>
{{/if}}
</ul>
</div>
</div>
</section>
{{!-- Native comments --}}
{{#if comments}}
<div class="m-comments">
<div class="l-wrapper in-comments js-native-comments">
{{comments}}
</div>
</div>
{{/if}}
{{!-- Third-party comments --}}
{{!--
<section class="m-comments">
<div class="l-wrapper in-comments js-third-party-comments">
<!-- Paste here the provided code snippet -->
</div>
</section>
--}}
{{/post}}
{{!-- Related posts --}}
{{#if post.tags.length}}
{{#get "posts" limit="3" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags,authors" order="published_at desc" as |related|}}
{{#if related}}
<section class="m-recommended">
<div class="l-wrapper in-recommended">
<h3 class="m-section-title in-recommended">{{t "Recommended for you"}}</h3>
<div class="m-recommended-articles">
<div class="m-recommended-slider swiper js-recommended-slider">
<div class="swiper-wrapper">
{{!-- The tag below iterates over all the related posts --}}
{{> "loop"}}
</div>
<button class="m-icon-button filled in-recommended-articles swiper-button-prev" aria-label="{{t "Previous"}}">
<span class="icon-arrow-left" aria-hidden="true"></span>
</button>
<button class="m-icon-button filled in-recommended-articles swiper-button-next" aria-label="{{t "Next"}}">
<span class="icon-arrow-right" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
</section>
{{/if}}
{{/get}}
{{/if}}
</div>
</article>
</main>
{{!-- The #contentFor helper here will send everything inside it up to the matching #block helper found in default.hbs --}}
{{#contentFor "scripts"}}
<script defer src="{{asset "js/post.js"}}"></script>
{{/contentFor}}
view raw post.hbs hosted with ❤ by GitHub

.

ทั้งหมดที่ทำก็จะเป็นประมาณนี้ ดู demo กันสักนิดนึงก่อนจบบล็อก

Reference

Tocbot
Tocbot - Generate a table of contents based on the heading structure of an html document

อันนี้ที่ official มา tutorial

How to add a table of contents to your Ghost site
Let your readers know what to expect in your posts and give them quick links to navigate content quickly by adding a table of contents with the Tocbot library.

มีคนลองทำแล้วไม่ได้แบบเราเลย ประชากรในคอมมูช่วยเหลือกันเยอะเลย

Adding a table of contents
Hi! blog.gocontentjungle.com 4.43.1 Hi, I was trying to follow this tutorial: How to add a table of contents to your Ghost site I would like to have it the same as the example in Advanced Styling. I added the example code in “screen.css” ( is that correct, or should I add it somewhere else? Al…

ติดตามข่าวสารตามช่องทางต่าง ๆ และทุกช่องทางโดเนทกันไว้ที่นี่เลย แนะนำให้ใช้ tipme เน้อ ผ่าน promptpay ได้เต็มไม่หักจ้า

ติดตามข่าวสารแบบไว ๆ มาที่ Twitter เลย บางอย่างไม่มีในบล็อก และหน้าเพจนะ

Tags

Minseo Chayabanjonglerd

I am a full-time Android Developer and part-time contributor with developer community and web3 world, who believe people have hard skills and soft skills to up-skill to da moon.