Casper 主题定制流程

casper主题使用gulp打包,ghost平台有gscan工具检查主题是否有效。

$ git clone https://github.com/TryGhost/Casper.git
$ cd Casper
$ npm install
$ ....          #修改hbs文件、或者asset/screen.css样式
$ gscan .       #检查修改是否符合规范
$ gulp build    #构建修改后的主题
$ gulp zip      #打包构建,生成文件在dist文件夹

将生成的文件通过ghost后台的「主题」功能上传

添加Valine评论 & 访问量统计

Valine一款简洁的评论插件,不需要账号登录,直接就可以评论,对个人博客、以及希望降低评论门槛的网站来说,应该够用了。

  • Step1: 免费注册 leancloud 账号(存储评论、访问量数据)
  • Step2: 修改post.hbs,在二级标题作者信息后增加访问量统计
<div class="post-full-byline">
  <section class="post-full-byline-content">
  ......
  </section>

  <!-- id 将作为查询条件 -->
  <span id="{{url}}" class="leancloud_visitors" data-flag-title="{{title}}"> <em class="post-meta-item-text">阅读量 </em>
      <i class="leancloud-visitors-count">100</i>
  </span>

</div>
  • Step3: 修改post.hbs文件,在正文后面增加评论section
<section class="post-full-comments">

    <!-- 添加 Valine 评论 -->
    <h3>评论区</h3>
    <div id="vcomments"></div>

</section>
  • Step3: 在ghost的code injection中加载Valine.min.js 并初始化
<!--Header中加载-->
<script src='//unpkg.com/valine/dist/Valine.min.js'></script>

<!--Footer中初始化-->
<script>
	new Valine({
		el: '#vcomments',
		appId: 'yAT64gg8kIlPG7icASxlYdqe-gzGzoHsz',
		appKey: 'qmSvqgTwUx83cjGH6fkenhx8',
        notify: true, 	//有新评论进行通知
    	    verify: true, 	//评论需要验证码
    	    avatar: 'identicon',
        meta: ['nick'], // mail, link,
        recordIP: true,	// 记录ip地址
        visitor: true,	// 阅读量统计
    	placeholder: '欢迎评论留言!'
	})
</script>

valine官网链接

添加侧边栏的目录

  • Step1: 修改post.hbs,在正文之前增加一个toc-container侧边栏
<header class="site-header">
    {{> site-header}}
</header>

{{!-- Everything inside the #post tags pulls data from the post --}}
{{#post}}

<!-- 添加目录Toc -->
<aside class="toc-container">
    <div class="toc"></div>
</aside>
  • Step2: 在asset/screen.css中增加toc-container的样式
/* toc */
.toc-container {
    position: fixed;
    bottom: 15px;
    max-width: calc(50% - 450px - 40px);
    font-size: 80%;
    z-index: 999;
    /* background: white; */
    /* color: rgba(0,0,0,.5); */
    padding: 0 10px 10px;
    border-radius: 0 3px 3px 0;
    box-sizing: border-box;
    background: #191b1f;
}

/* Offset headings from fixed header */
.post-content h1::before,
.post-content h2::before,
.post-content h3::before,
.post-content h4::before {
    display: block;
    content: " ";
    height: 84px;
    margin-top: -84px;
    visibility: hidden;
}

/* Adjust content wrapper */
.post-content {
    display: block;
}

/* Adjustments to wide and full width cards */
.kg-gallery-card,
.kg-width-wide,
.kg-width-full {
    display: flex;
    flex-direction: column;
    align-items: center;
}

.kg-gallery-card > *,
.kg-width-wide > *,
.kg-width-full > *,
figure.kg-width-full img {
    margin-left: -50vw;
    margin-right: -50vw;
}

.post-full-content pre {
    max-width: 0;
}
  • Step3: 主题打包并上传到ghost后台
  • Step4: 在Code Injection加载tocbot的js和css文件
<!--在Header中加载样式-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.css">

<!--在Footer中加载js并初始化-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.11.1/tocbot.min.js"></script>
<script>
    tocbot.init({
        tocSelector: '.toc',
        contentSelector: '.post-content',
        headingSelector: 'h1, h2, h3',
    });
</script>

搜索Flexsearch

以前用ghosthunter,效果太差,后来陆续出来了fuse、ghost-search、flexsearch等解决方案。这些方案共同点在于
通过ghost自带的content-api.min.js从ghost后台读取全部内容并进行index、搜索,基本完成了一个search engine的流程。

几种解决方案:

  • flexsearch: 当前采用方案,分词、索引全套,既可以做单独服务,也可以嵌入页面。
  • ghost search: 没用过
  • fuse: 小巧方便,但不支持中文

操作步骤

  • Step1: 在导航栏最右边增加搜索入口,partials/site-nav.hbs
<button class="m-icon-button in-menu-main js-open-search" aria-label="Open search">
    <span class="icon-search"></span>
</button>
  • Step2: 新增search icon和close icon
    使用bootstrap的icon,直接用绘制,不用额外引入字体文件,简单便捷
  • search图标:partials/icons/search.hbs
<svg class="bi bi-search" width="16px" height="16px" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
    <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
    <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
</svg>
  • close图标:partials/icons/x.hbs
<svg class="bi bi-x" width="24px" height="24px" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
    <path fill-rule="evenodd" d="M11.854 4.146a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708-.708l7-7a.5.5 0 0 1 .708 0z"/>
    <path fill-rule="evenodd" d="M4.146 4.146a.5.5 0 0 0 0 .708l7 7a.5.5 0 0 0 .708-.708l-7-7a.5.5 0 0 0-.708 0z"/>
</svg>

bootstrap-icons

  • Step3:新增搜索框及partials/search.hbs
<div class="m-search js-search">
  <button class="m-icon-button outlined as-close-search js-close-search" aria-label="Close search">
    <span class="icon-close">{{> "icons/x"}}</span>
  </button>
  <div class="m-search__content">
    <form class="m-search__form">
      <fieldset>
        <span class="icon-search m-search-icon">{{> "icons/search"}}</span>
        <input type="text" class="m-input in-search js-input-search" placeholder="{{t "Type to search"}}" aria-label="Type to search">
      </fieldset>
    </form>
    <div class="js-search-results hide"></div>
    <p class="m-not-found align-center hide js-no-results">
      {{t "No Results."}}
    </p>
  </div>
</div>
  • Step4: 在default.hbs中增加search框
......
{{!-- All the main content gets inserted here, index.hbs, post.hbs, etc --}}
{{{body}}}

{{!-- Search form --}}
{{> search}}
......
  • Step5: 在partials/site-nav.hbs中增加搜索入口
<button class="m-icon-button in-menu-main js-open-search" aria-label="Open search">
    <span class="icon-search">{{> "icons/search"}}</span>
</button>
  • Step6: 新增search.css样式表
:root,[data-theme=dark] {
        --background-color:#111;
        --primary-foreground-color:#ccc;
        --secondary-foreground-color:#fff;
        --primary-subtle-color:#2c2fe6;
        --secondary-subtle-color:#141920;
        --titles-color:#b4b4b4;
        --link-color:#2c2fe6;
        --primary-border-color:#1d1d1d;
        --secondary-border-color:#0f0f0f;
        --article-shadow-normal:0 4px 5px 5px rgba(0,0,0,0.1);
        --article-shadow-hover:0 4px 5px 10px rgba(0,0,0,0.1);
        --transparent-background-color:rgba(0,0,0,0.99);
        --footer-background-color:#080808;
        --submenu-shadow-color-opacity:0.55;
        --button-shadow-color-normal:rgba(10,10,10,0.5);
        --button-shadow-color-hover:rgba(10,10,10,0.5);
        --toggle-darkmode-button-color:#efd114;
        --table-background-color-odd:#050505;
        --table-head-border-bottom:#1d1d1d;
}

.clearfix:after,.clearfix:before {
    content: " ";
    line-height: 0;
    display: table
}

.clearfix:after {
    clear: both
}

.clearfix {
    *zoom:1}

.content-centered,.m-hero,.m-icon-button {
    display: flex;
    align-items: center;
    justify-content: center
}

.hide {
    display: none
}

.m-icon-button {
    color: var(--titles-color);
    font-size: 1.125rem;
    border: 0;
    outline: 0;
    padding: 0;
    cursor: pointer;
    background-color: transparent
}

.m-icon-button.outlined {
    border-radius: 50%;
    border: 1px solid var(--primary-foreground-color)
}

.m-icon-button.filled {
    background-color: var(--background-color);
    border-radius: 50%;
    -o-box-shadow: 0 2px 4px var(--button-shadow-color-normal),0 0 0 transparent;
    box-shadow: 0 2px 4px var(--button-shadow-color-normal),0 0 0 transparent;
    transition: all .25s cubic-bezier(.02,.01,.47,1)
}

.m-icon-button.filled:hover {
    -o-box-shadow: 0 4px 8px var(--button-shadow-color-hover),0 0 0 transparent;
    box-shadow: 0 4px 8px var(--button-shadow-color-hover),0 0 0 transparent
}

.m-icon-button.in-mobile-topbar {
    width: 65px;
    height: 100%
}

.m-icon-button.as-close-menu {
    position: absolute;
    top: 20px;
    right: 20px;
    width: 32px;
    height: 32px;
    font-size: .625rem;
    z-index: 2
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.as-close-menu {
        display:none!important
    }
}

.m-icon-button.as-close-search {
    position: absolute;
    top: 20px;
    right: 20px;
    width: 32px;
    height: 32px;
    font-size: .625rem;
    z-index: 2
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.as-close-search {
        top:30px;
        right: 30px;
        width: 42px;
        height: 42px;
        font-size: .875rem
    }
}

@media only screen and (min-width: 80rem) {
    .m-icon-button.as-close-search {
        top:40px;
        right: 40px;
        width: 50px;
        height: 50px
    }
}

.m-icon-button.in-menu-main {
    display: none
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.in-menu-main {
        display:flex;
        width: 26px;
        height: 25px
    }
}

.m-icon-button.more {
    font-size: 6px;
    z-index: 6;
    position: relative
}

.m-icon-button.more.active {
    color: var(--primary-subtle-color)
}

.m-icon-button.in-pagination-left,.m-icon-button.in-pagination-right {
    width: 40px;
    height: 40px;
    font-size: .625rem
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.in-pagination-left,.m-icon-button.in-pagination-right {
        width:46px;
        height: 46px;
        font-size: .688rem
    }
}

.m-icon-button.in-pagination-left {
    margin-right: 30px
}

.m-icon-button.in-pagination-right {
    margin-left: 30px
}

.m-icon-button.in-featured-articles {
    position: absolute;
    color: #fff;
    font-size: .875rem;
    width: 29px;
    height: 22px;
    bottom: 16px;
    z-index: 2
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.in-featured-articles {
        bottom:36px
    }
}

.m-icon-button.in-featured-articles.slick-prev {
    right: 52px
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.in-featured-articles.slick-prev {
        right:72px
    }
}

.m-icon-button.in-featured-articles.slick-next {
    right: 16px
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.in-featured-articles.slick-next {
        right:36px
    }
}

.m-icon-button.in-recommended-articles {
    position: absolute;
    font-size: .625rem;
    width: 40px;
    height: 40px;
    top: 200px;
    z-index: 2;
    transform: translateY(-50%)
}

.m-icon-button.in-recommended-articles.slick-prev {
    left: 0
}

.m-icon-button.in-recommended-articles.slick-next {
    right: 0
}

.m-icon-button.as-load-comments {
    position: relative;
    width: 60px;
    height: 60px;
    font-size: 1.25rem;
    margin: 0 auto;
    z-index: 2
}

@media only screen and (min-width: 48rem) {
    .m-icon-button.as-load-comments {
        width:80px;
        height: 80px;
        font-size: 1.625rem
    }
}

.m-icon-button.in-share {
    color: var(--titles-color);
    font-size: .75rem;
    text-decoration: none;
    width: 31px;
    height: 31px;
    margin: 0 25px
}

@media only screen and (min-width: 64rem) {
    .m-icon-button.in-share {
        font-size:.875rem;
        width: 40px;
        height: 40px;
        margin: 0 0 20px
    }
}

@media only screen and (min-width: 80rem) {
    .m-icon-button.in-share {
        font-size:1rem;
        width: 50px;
        height: 50px
    }
}

.m-icon-button.progress {
    position: relative
}

.m-icon-button.progress svg {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    opacity: 0
}

.m-icon-button.progress svg circle {
    stroke: var(--primary-subtle-color);
    transform-origin: 50% 50%;
    transform: rotate(-90deg);
    transition: stroke-dashoffset .2s
}


.m-search {
    visibility: hidden;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    overflow-y: auto;
    z-index: 2;
    background-color: var(--transparent-background-color);
    -webkit-overflow-scrolling: touch;
    transform: scale(1.2);
    transition: all .4s cubic-bezier(.165,.84,.44,1)
}

.m-search.opened {
    visibility: visible;
    opacity: 1;
    z-index: 1000;
    transform: scale(1)
}

.m-search__content {
    padding: 80px 20px 40px;
    margin: 0 auto
}

@media only screen and (min-width: 48rem) {
    .m-search__content {
        padding-top:100px;
        padding-bottom: 50px;
        max-width: 700px
    }
}

@media only screen and (min-width: 80rem) {
    .m-search__content {
        padding-left:0;
        padding-right: 0
    }
}

@media only screen and (min-width: 90rem) {
    .m-search__content {
        max-width:800px
    }
}

.m-search__form {
    margin-bottom: 30px
}

@media only screen and (min-width: 48rem) {
    .m-search__form {
        max-width:500px;
        margin: 0 auto 45px
    }
}

.m-search-icon {
    position: absolute;
    top: 45%;
    left: 15px;
    color: #9b9b9b;
    font-size: 1rem;
    font-weight: 500;
    pointer-events: none;
    transform: translateY(-45%)
}

@media only screen and (min-width: 48rem) {
    .m-search-icon {
        font-size:1.25em;
        left: 25px
    }
}

.m-result {
    border-bottom: 1px solid var(--primary-border-color)
}

.m-result.last {
    border-bottom: 0
}

.m-result__link {
    display: block;
    width: 100%;
    height: 100%;
    padding: 10px 0
}

@media only screen and (min-width: 48rem) {
    .m-result__link {
        padding:15px 0
    }
}

.m-result__title {
    color: var(--primary-foreground-color);
    letter-spacing: .3px;
    line-height: 1.4;
    font-size: 1rem;
    font-weight: 400;
    margin: 0 0 5px
}

@media only screen and (min-width: 48rem) {
    .m-result__title {
        letter-spacing:.4px;
        font-size: 1.25rem;
        margin-bottom: 10px
    }
}

@media only screen and (min-width: 80rem) {
    .m-result__title {
        font-size:1.375rem
    }
}

.m-result__date {
    color: var(--titles-color);
    letter-spacing: .2px;
    font-size: .813rem
}

@media only screen and (min-width: 48rem) {
    .m-result__date {
        letter-spacing:.3px;
        font-size: .938rem
    }
}


*,:after,:before {
    background-repeat: no-repeat;
    box-sizing: border-box
}

.m-not-found {
    color: var(--primary-foreground-color);
    line-height: 1.3;
    font-size: .875rem;
    font-weight: 600
}

.m-not-found.in-recent-articles {
    margin-left: 20px
}

@media only screen and (min-width: 48rem) {
    .m-not-found.in-recent-articles {
        margin-left:0
    }
}

.l-post-content input,.l-post-content select,.l-post-content textarea,.m-input {
    color: var(--primary-foreground-color);
    letter-spacing: .2px;
    line-height: 1.3;
    font-size: 1rem;
    width: 100%;
    border-radius: 5px;
    padding: 11px 15px;
    border: 1px solid var(--primary-border-color);
    outline: 0;
    background-color: var(--background-color)
}

.l-post-content input.in-search,.l-post-content select.in-search,.l-post-content textarea.in-search,.m-input.in-search {
    font-weight: 600;
    padding-left: 40px
}

@media only screen and (min-width: 48rem) {
    .l-post-content input.in-search,.l-post-content select.in-search,.l-post-content textarea.in-search,.m-input.in-search {
        font-size:1.25rem;
        padding: 15px 30px 15px 60px
    }
}

.l-post-content input.in-subscribe-section,.l-post-content select.in-subscribe-section,.l-post-content textarea.in-subscribe-section,.m-input.in-subscribe-section {
    margin-bottom: 15px
}
  • Step7: 获取content-api.min.js放入assets/js/lib目录
wget https://unpkg.com/@tryghost/content-api@1.4.1/umd/content-api.min.js assets/js/lib
  • Step8: 在default.hbs文件加载search.css并新增搜索触发、索引、搜索代码
<link rel="stylesheet" type="text/css" href="{{asset "built/search.css"}}" />

<script>
function formatDate(date) {
    if (date) {
        return new Date(date).toLocaleDateString(
            document.documentElement.lang,
            {
                year: 'numeric',
                month: 'long',
                day: 'numeric'
            }
        )
    }

    return ''
}

$(document).ready(() => {
    const $body = $('body')
    const $openSearch = $('.js-open-search')
    const $closeSearch = $('.js-close-search')
    const $search = $('.js-search')
    const $inputSearch = $('.js-input-search')
    const $searchResults = $('.js-search-results')
    const $searchNoResults = $('.js-no-results')

    let flexIndexZh = null
    let flexIndexEn = null

    function trySearchFeature() {
        if (typeof ghostSearchApiKey !== 'undefined' && typeof ghostHost !== 'undefined') {
            getAllPosts(ghostHost, ghostSearchApiKey)
        } else {
            $openSearch.css('visibility', 'hidden')
            $closeSearch.remove()
            $search.remove()
        }
    }

    function toggleScrollVertical() {
        $body.toggleClass('no-scroll-y')
    }

    function getAllPosts(host, key) {
        const api = new GhostContentAPI({
            url: host,
            key,
            version: 'v2'
        })
        const allPosts = []
        const docOption = {
            doc: {
                id: "id",
                field: [
                    "title",
                    "html",
                    "custom_excerpt"
                ]
            }
        }
        const flexOptionsZh = {
            encode: false,
            tokenize: function(str){
                return str.replace(/[\x00-\x7F]/g, "").split("");
            }
        }

        const flexOptionsEn = {
            encode: 'advanced',
            tokenize: 'forward'
        }

        api.posts.browse({
            limit: 'all',
            fields: 'id, title, url, published_at, custom_excerpt, html'
        })
            .then((posts) => {
                for (var i = 0, len = posts.length; i < len; i++) {
                    allPosts.push(posts[i])
                }

                //fuse = new Fuse(allPosts, fuseOptions)

                flexIndexZh = new FlexSearch(Object.assign({}, flexOptionsZh, docOption))
                flexIndexZh.add(allPosts)

                flexIndexEn = new FlexSearch(Object.assign({}, flexOptionsEn, docOption))
                flexIndexEn.add(allPosts)
            })
            .catch((err) => {
                console.log(err)
            })

    }

    $openSearch.click(() => {
        $search.addClass('opened')
        setTimeout(() => {
            $inputSearch.focus()
        }, 400);
        toggleScrollVertical()
    })

    $closeSearch.click(() => {
        $inputSearch.blur()
        $search.removeClass('opened')
        toggleScrollVertical()
    })

    $inputSearch.keyup(() => {
        if ($inputSearch.val().length > 0 && flexIndexEn && flexIndexZh) {
            const resultsZh = flexIndexZh.search($inputSearch.val())
            const resultsEn = flexIndexEn.search($inputSearch.val())

            const results = resultsZh.concat(resultsEn)
            let htmlString = ''

            if (results.length > 0) {
                let duplicate_ids = []
                for (var i = 0, len = results.length; i < len; i++) {
                    if (results[i].id in duplicate_ids)
                        continue
                    htmlString += `
  <article class="m-result">\
    <a href="${results[i].url}" class="m-result__link">\
      <h3 class="m-result__title">${results[i].title}</h3>\
      <span class="m-result__date">${formatDate(results[i].published_at)}</span>\
    </a>\
  </article>`
                    duplicate_ids.push(results[i].id)
                }

                $searchNoResults.hide()
                $searchResults.html(htmlString)
                $searchResults.show()
            } else {
                $searchResults.html('')
                $searchResults.hide()
                $searchNoResults.show()
            }
        } else {
            $searchResults.html('')
            $searchResults.hide()
            $searchNoResults.hide()
        }
    })

    trySearchFeature()
})
</script>

Step9: 打包新主题并上传ghost后台

Step10: 在ghost后台传递使用content-api.min.js的关键参数
需要先在ghost后台新增自定义插件并获取content api key

<script>
  const ghostHost = 'https://huhao.ai'
  const ghostSearchApiKey = 'd017b25xxxxxxx59c08f3'     //ghost后台获取的content api key
</script>

其他参考

语法高亮Prismjs

  • Step1: 在prismjs官网组合自己喜欢的配色、需要用到的plugin。
    这里选的是solarizedlight主题,搭配了line-numbers、command-line、copy-to-clipboard插件
https://prismjs.com/download.html#themes=prism-solarizedlight&languages=markup+css+clike+javascript+bash+python&plugins=line-highlight+line-numbers+show-language+highlight-keywords+command-line+toolbar+copy-to-clipboard+treeview
  • Step2: 将下载的css文件和js文件分别放在asset目录的css和js目录中
  • Step3: 在defaults.hbs文件中加载css样式并打包主题上传到后台
......
<link rel="stylesheet" type="text/css" href="{{asset "built/prism.css"}}" />

{{ghost_head}}
......
  • Step4: 在ghost后台injection code调整最终样式以及增加代码行计数
<!--在Header中微调字体大小-->
<style>
    pre[class*=language-] {
        margin: 1.75em 0;
        font-size: 1.4rem;
    }
</style>

<!--在Footer中调整pre样式,增加line-numbers样式-->
<script>
    window.addEventListener('DOMContentLoaded', (event) => {
        document.querySelectorAll('pre[class*=language-]').forEach(function(node) {
            node.classList.add('line-numbers');
		});
        Prism.highlightAll();
    });
</script>

Google analysis

打开google analysis网站,新建一个监控,获取代码,并在code injection中加载

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-46074451-3"></script>

<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-46074451-3');
</script>

一些可以参考的主题

常见问题

Uglify升级

现象

GulpUglifyError: unable to minify JavaScript Caused by:
SyntaxError: Unexpected token name «k», expected punc «;»

解决方案

替换gulpfile.js里的uglify模块,使用支持es语法的gulp-uglify-es替代gulp-uglify

var uglify = require('gulp-uglify-es').default;

npm缓存清理

npm cache verify
npm cache clean --force

toc目录有些链接失效

现象
toc链接,纯中文标题无效,有英文字符的标题有效

解决
ghost使用markdown格式下,自动生成的id会过滤掉非英文字符,导致纯中文标题的id为空。因此tocbot生成的标题无法有效跳转。
需要使用ghost推荐的koenig编辑器替代markdown编辑器。

ID names of HTML elements (headings) are missing special characters #9740