เพิ่ม table of content สำหรับ Ghost CMS กัน
เนื่องจากเราเองคิดว่าคนอ่านอาจจะอยากได้ตัว 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> |
.
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">•</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}} |
.
ทั้งหมดที่ทำก็จะเป็นประมาณนี้ ดู demo กันสักนิดนึงก่อนจบบล็อก
Reference
อันนี้ที่ official มา tutorial

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


ติดตามข่าวสารตามช่องทางต่าง ๆ และทุกช่องทางโดเนทกันไว้ที่นี่เลย แนะนำให้ใช้ tipme เน้อ ผ่าน promptpay ได้เต็มไม่หักจ้า
ติดตามข่าวสารแบบไว ๆ มาที่ Twitter เลย บางอย่างไม่มีในบล็อก และหน้าเพจนะ
สวัสดีจ้า ฝากเนื้อฝากตัวกับชาวทวิตเตอร์ด้วยน้าา
— Minseo | Stocker DAO (@mikkipastel) August 24, 2020