站内搜索

“Typecho” 找到 27 条结果

用 AI 重新折腾 Firekylin

文章回顾了作者与 Firekylin 的长期渊源,并重点分享了这次借助 AI 对老项目进行大规模升级改造的过程与感受。作者将 Firekylin 从多年积累的旧技术栈中全面升级,包括基础库、React 组件写法、模板引擎以及插件体系等,认为 AI 在处理这类跨版本迁移、重复性重构和结合上下文的大量修改时表现非常高效。AI 正在改变老项目维护和升级的工作方式。开发者的角色从亲自逐行实现,更多转向明确目标、拆分需求和进行审查判断。借助 AI,那些原本因工作量过大而长期搁置的改造,如今变得更容易推进。

Hugo 主题 Eureka 自定义

今天有网友邮件我咨询我现在的主题 Eureka 的一些自定义配置,他想参考一下。由于我的博客仓库是私有的,所以就写一篇文章简单整理一下。

Eureka 是前段时间群友推荐给我的,纯白的朴素风格同时提供了暗色模式瞬间我就喜欢上了。将其 clone 到 Hugo 博客目录 themes/hugo-eureka 下,config.toml 中配置 theme = "hugo-eureka" 即可使用上该款主题。为了方便主题的更新,我将我所有自定义的模板都放在了 layouts 目录下。Hugo 会将主题目录和 layouts 目录下的文件进行合并,并优先使用 layouts 目录中的同名文件。这样之后我只需要单纯的更新 thtmes/hugo-eureka 目录即可。

首页

相较于 Eureka 主题的默认首页,我个人还是比较喜欢传统博客的两栏布局,左侧显示模块列表,右侧显示文章列表,所以我需要自定义首页模板。拷贝以下内容创建 layouts/index.html 文件即可实现同款。

{{ define "main" }}
<div class="pl-scrollbar">
  <div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
    <div class="max-w-screen-xl mx-auto" style="padding-top: 3rem">
      <div class="bg-local bg-cover">
        <img class="day" src="/banner-day.png" />
        <img class="dark" src="/banner.png" />
      </div>
    </div>
    <!-- <article class="mx-6 my-7">
      <h1 class="font-bold text-3xl text-primary-text"></h1>
    </article> -->
    <div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
      <div class="col-span-2 sidebar">
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">最新文章</h2>
          <ul class="widget-list">
            {{- $recent := default 5 .Site.Params.numberOfRecentPosts }}
            {{- $posts := where (where .Site.RegularPages "Permalink" "!=" .Permalink) "Type" "in" .Site.Params.mainSections }}
            {{- range first $recent $posts }}
            <li>
              <a href="{{ .RelPermalink }}" class="nav-link">{{ .Title }}</a>
            </li>
            {{- end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          {{ $walineURL := .Site.Params.comment.waline.serverURL }}
          <h2 class="widget-title ">最近回复</h2>
          <ul class="widget-list recentcomments">
            {{ $resp := getJSON $walineURL "/comment?type=recent&count=10" }}
            {{ range first 10 $resp }}
            <li class="recentcomments">
              <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">友情链接</h2>
          <ul class="widget-list">
            {{ range .Site.Menus.friends }}
            <li>
              <a href="{{ .URL }}">{{ .Name }}</a>
            </li>
            {{ end }}
          </ul>
        </div>
        <div class="widget bg-secondary-bg rounded p-6">
          <h2 class="widget-title">管理</h2>
          <ul class="widget-list">
            <li>
              <a href="/admin">🛠 后台管理</a>
            </li>
            <li>
              <a href="{{ .Site.Params.comment.waline.serverURL }}/ui">💬 评论管理</a>
            </li>
          </ul>
        </div>
      </div>
      <div class="col-span-2  lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <div class="bg-secondary-bg rounded overflow-hidden px-4 divide-y">
          {{ range .Paginator.Pages }}
          <div class="px-2 py-6">
              {{ partial "components/summary-plain.html" . }}
          </div>
          {{ end }}
        </div>
        {{ template "_internal/pagination.html" . }}
      </div>
    </div>
  </div>
</div>
{{ end }}

其中顶部还增加了一组暗色模式切换的横幅图片,添加以下 CSS 内容至 layouts/partials/custom-head.html 文件中,不存在的话需要新建。

.widget + .widget {
  margin-top: 1rem;
}
.widget-title {
  font-weight: bold;
  margin-bottom: 1rem;
}
.widget-list li {
  font-size: 0.9rem;
}
.bg-cover img {
  opacity: 1;
  transition: all .5s ease-in-out;
}
.bg-cover img.dark {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.day {
  opacity: 0;
  height: 0;
}
.dark .bg-cover img.dark {
  opacity: 1;
  height: auto;
}

左侧的模块中,评论是使用了本人自研的 Waline 评论系统并进行了一定的改造,具体可参见我之前的文章《静态博客如何高性能插入评论》。当然也可以直接使用 Waline 自带的最近评论挂件。

友情链接则是在 config.toml 中按照如下格式进行配置的。

[[menu.friends]]
name = "童欧巴博客"
url = "https://hungryturbo.com/"
weight = 20

[[menu.friends]]
identifier = "QingXu"
name = "QingXu"
url = "https://blog.qingxu.live"
weight = 19

[[menu.friends]]
identifier = "蜘蛛抱蛋"
name = "蜘蛛抱蛋"
url = "https://blog.zzbd.org/"
weight = 18

后台管理则是使用了 forestry 提供的服务,它支持提供在线后台进行文章、页面和其它配置的管理。评论管理则是链接到了 Waline 服务的后台面板中。

Metadata

Eureka 主题的文章 metadata 显示分为列表页和详情页两个,分别对应 post_metadata.html 和 post_metadata_full.html 两个文件。我们在 layouts/partials/ 目录下新建这两个文件用来覆盖主题默认的文件。

2021-03-13 更新: 更新后的 Eureka 统一使用了 components/post-metadata.html 显示文章的 metadata,代码和之前的 layouts/partials/post_metadata.html 是一致的。

{{/* layouts/partials/components/post_metadata.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{- $slug := printf "/%s.html" .Slug}}
  {{- $commentsData := (partialCached "utils/get-comments.html" .)}}
  
  {{- $comments := slice }}
  {{- range where $commentsData "url" "==" $slug}}
    {{$comments = $comments | append .}}
  {{- end}}
  {{- $count := len $comments}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $count 0}}{{$count}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

post_metadata.html 主要是增加了评论条数的显示,而 post_metadata_full.html 中还增加了 Markdown 原文链接的显示。关于如何生成 Markdown 原文链接,可以参考我之前的文章《Hugo 之旅》。

{{/* layouts/partials/post_metadata_full.html */}}
<div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
  <div class="mr-6 my-2">
      <i class="fas fa-calendar mr-1"></i>
      <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
  </div>

  {{$resp := getJSON "https://imerd.comment.lithub.cc/comment?type=count&url=/" .Slug ".html" }}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}#waline-comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>
  </div>

  {{ if eq .Type "posts" -}}
  {{ with .OutputFormats.Get "MarkDown" -}}
  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-book mr-1"></i>
      <span>阅读Markdown格式</span>
    </a>
  </div>
  {{- end }}
  {{ end }}

  <div class="mr-6 my-2">
    <a href="{{ .Permalink }}">
      <i class="fas fa-pen mr-1"></i>
      <span>{{ .WordCount }} 字</span>
    </a>
  </div>

  <div class="mr-6 my-2">
      <i class="fas fa-clock mr-1"></i>
      <span>{{ i18n "readingTime" . }}</span>
  </div>
  
  {{ with .GetTerms "categories" }}
  <div class="mr-6 my-2">
      <i class="fas fa-folder mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}

  {{ with .GetTerms "series" }}
  <div class="mr-6 my-2">
      <i class="fas fa-th-list mr-1"></i>
      {{ range $index, $value := . }}
      {{ if gt $index 0 }}
      <span>, </span>
      {{ end -}}
      <a href="{{ .Permalink }}" class="hover:text-eureka">{{ .LinkTitle }}</a>
      {{ end }}
  </div>
  {{ end }}
</div>

搜索框

搜索也是博客比较重要的功能,为了方便我在顶部增加了搜索框。创建 layouts/partials/header.html 文件用来覆盖默认的头部模板。

{{/* layouts/partials/header.html */}}
<script>
    let storageColorScheme = localStorage.getItem("lightDarkMode")
    {{- if eq .Site.Params.colorScheme "light" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if ((storageColorScheme == 'Auto' && window.matchMedia("(prefers-color-scheme: light)").matches) || storageColorScheme == "Light") {
        document.getElementsByTagName('html')[0].classList.remove('dark')
    }
    {{- else }}
    if (((storageColorScheme == 'Auto' || storageColorScheme == null) && window.matchMedia("(prefers-color-scheme: dark)").matches) || storageColorScheme == "Dark") {
        document.getElementsByTagName('html')[0].classList.add('dark')
    }
    {{- end }}
</script>
<nav class="flex items-center justify-between flex-wrap px-4 py-4 md:py-0">
    <a href="{{ "/" | relLangURL }}" class="mr-6 text-primary-text text-xl font-bold">{{ .Site.Title }}</a>
    <button id="navbar-btn" class="md:hidden flex items-center px-3 py-2" aria-label="Open Navbar">
        <i class="fas fa-bars"></i>
    </button>

    <div id="target"
        class="hidden block md:flex md:flex-grow md:justify-between md:items-center w-full md:w-auto text-primary-text z-20">
        <div class="md:flex md:h-16 text-sm md:flex-grow pb-4 md:pb-0 border-b md:border-b-0">
            {{- $relPermalink := .RelPermalink }}
            {{- range .Site.Menus.main }}
            {{- $url := .URL | relLangURL }}
            <a href="{{ $url }}" class="block mt-4 md:inline-block md:mt-0 md:h-(16-4px) md:leading-(16-4px) box-border md:border-t-2 md:border-b-2 {{ if hasPrefix $relPermalink $url }} selected-menu-item {{ else }} border-transparent {{ end }} mr-4">{{ .Name }}</a>
            {{- end }}
        </div>

        <div class="flex">
            <div class="search-container relative pt-4 md:pt-0">
                <div class="search">
                    <form role="search" class="search-form" action="/search.html" method="get">
                    <label>
                        <input name="q" type="text" placeholder="搜索 ..." class="search-field">
                    </label>
                    <button>
                        <i class="fas fa-search"></i>
                    </button>
                    </form>
                </div>
            </div>

            <div class="relative pl-4 pt-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="lightDarkMode">
                    {{- if eq .Site.Params.colorScheme "dark" }}
                    <i class="fas fa-moon"></i>
                    {{- else if eq .Site.Params.colorScheme "light" }}
                    <i class="fas fa-sun"></i>
                    {{- else }}
                    <i class="fas fa-adjust"></i>
                    {{- end }}
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='lightDarkOptions'>
                    <span class="px-4 py-1 hover:text-eureka" name="Light">{{i18n "light"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Dark">{{i18n "dark"}}</span>
                    <span class="px-4 py-1 hover:text-eureka" name="Auto">{{i18n "auto"}}</span>
                </div>
            </div>

            {{- if .IsTranslated }}
            <div class="relative pt-4 pl-4 md:pt-0">
                <div class="cursor-pointer hover:text-eureka" id="languageMode">
                    <i class="fas fa-globe"></i>
                    <span class="pl-1">{{ .Language.LanguageName }}</span>
                </div>
                <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-30" id="is-open-lang">
                </div>
                <div class="absolute flex flex-col left-0 md:left-auto right-auto md:right-0 hidden bg-secondary-bg w-48 rounded py-2 border border-tertiary-bg cursor-pointer z-40"
                    id='languageOptions'>
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- range .Translations }}
                    <a class="px-4 py-1 hover:text-eureka" href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
                    {{- end }}
                </div>
            </div>
            {{- end }}
        </div>
    </div>

    <div class="fixed hidden inset-0 opacity-0 h-full w-full cursor-default z-0" id="is-open-mobile">
    </div>

</nav>
<script>
    let element = document.getElementById('lightDarkMode')
    {{- if eq .Site.Params.colorScheme "light" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-sun')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- else if eq .Site.Params.colorScheme "dark" }}
    if (storageColorScheme == 'Auto') {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'adjust')
        element.firstElementChild.classList.add('fa-adjust')
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-moon')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    }
    {{- else }}
    if (storageColorScheme == null || storageColorScheme == 'Auto') {
        document.addEventListener('DOMContentLoaded', () => {
            switchMode('Auto')
        })
    } else if (storageColorScheme == "Light") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'sun')
        element.firstElementChild.classList.add('fa-sun')
    } else if (storageColorScheme == "Dark") {
        element.firstElementChild.classList.remove('fa-adjust')
        element.firstElementChild.setAttribute("data-icon", 'moon')
        element.firstElementChild.classList.add('fa-moon')
    }
    {{- end }}

    document.addEventListener('DOMContentLoaded', () => {
        getcolorscheme();
        switchBurger();
        {{- if .IsTranslated }}
        switchLanguage()
        {{- end }}
    });
</script>

大部分的内容都是 Eureka 主题提供的,除了增加了 #search-container 搜索框部分。为了让搜索框更美观一点,我在 layouts/partials/custom-head.html 中自定义了一些样式。

.search-container {
  margin-top: -0.3rem;
}
.search-container .search {
  border: 1px solid #e2e8f0;
  border-radius: 4px;
}
.search-container input {
  padding-left: 1rem;
  line-height: 2rem;
  outline: none;
  background: transparent;
}
.search-container button {
  font-size: 0.8rem;
  margin-right: 0.5rem;
  color: #e2e8f0;
}

最终搜索框跳转至单独的搜索页面。关于如何给 Hugo 博客添加搜索功能,可查看我之前的文章 《Hugo 之旅》。我这边提供一下我的搜索结果页模板。

{{/* layouts/_default/search.html */}}
{{ define "main" }}
<div class="w-full max-w-screen-xl lg:px-4 xl:px-8 mx-auto">
  <article class="mx-6 my-8">
      <h1 id="search-count" class="font-bold text-3xl text-primary-text"></h1>
  </article>
  <div id="search-result" class="bg-secondary-bg rounded overflow-hidden px-4 divide-y"> 
    
  </div> 
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', async () => {
    const qs = new URLSearchParams(location.search);
    const searchResult = document.querySelector('#search-result');
    const searchCount = document.querySelector('#search-count');

    const fuseOptions = {
      shouldSort: true,
      includeMatches: true,
      threshold: 0.0,
      tokenize: true,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1,
      keys: [{
          name: "title",
          weight: 0.8
        },
        {
          name: "summary",
          weight: 0.5
        },
        {
          name: "tags",
          weight: 0.3
        },
        {
          name: "date",
          weight: 0.3
        },
      ]
    };

    let fuse = null

    async function getFuse() {
      if (fuse == null) {
        const resp = await fetch('/index.json', {
          method: 'get'
        })
        const indexData = await resp.json()
        fuse = new Fuse(indexData, fuseOptions);
      }
      return fuse
    }

    function render(items) {
      console.log(items);
      return items.map(item => {
        item = item.item
        return `
<div class="px-2 py-6">
  <div class="flex flex-col-reverse lg:flex-row justify-between">
    <div class="w-full lg:w-2/3">
      <div class="my-2">
        <div class="mb-4">
          <a href="${item.permalink}" class="font-bold text-xl hover:text-eureka">${item.title}</a>
        </div>
        <div class="content">
          ${item.summary}
          <p class="more">
            <a href="${item.permalink}" title="${item.title}">阅读全文<span class="meta-nav">→</span></a>
          </p>
        </div>
      </div>
      <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
        <div class="mr-6 my-2">
          <i class="fas fa-calendar mr-1"></i>
          <span>${item.date}</span>
        </div>
        <div class="mr-6 my-2">
          <a href="${item.permalink}#waline-comments" title="${item.title}">
            <i class="fas fa-comment mr-1"></i>
            <span>${item.comments > 0 ? item.comments + ' 条评论' : '暂无评论'}</span>
          </a>
        </div>
        <div class="mr-6 my-2">
          <i class="fas fa-clock mr-1"></i>
          <span>${item.time} 分钟阅读时长</span>
        </div>
      </div>  
    </div>
    <div class="w-full lg:w-1/3 mb-4 lg:mb-0 lg:ml-8">
      ${item.featuredImage ? `<img src="${item.featuredImage}" class="w-full" alt="Featured Image">` : ''}
    </div>
  </div>
</div>`;
      }).join('');
    }

    function updateDOM(html, keyword, number) {
      document.title = document.title.replace(/包含关键词.*?文章/, `包含关键词 ${keyword} 的文章`)
      searchResult.innerHTML = html
      searchCount.innerHTML = `共查询到 ${number} 篇文章`
    }

    async function search(searchString) {
      console.log(searchString);
      let result = [];
      if(searchString) {
        const fuse = await getFuse()
        result = fuse.search(searchString)
      }
      const html = render(result)
      updateDOM(html, searchString, result.length)
    }

    document.querySelectorAll('input[name="q"]').forEach(el => el.value = qs.get('q'));
    search(qs.get('q') || '')

    window.blogSearch = function(keyword) {
      if(!keyword) {
        return;
      }

      history.pushState('', '', location.pathname + '?q=' + encodeURIComponent(keyword));
      document.querySelectorAll('input[name="q"]').forEach(el => el.value = keyword);
      search(keyword);
    }
  })
</script>
{{ end }}

归档

之前使用 Typecho 的时候有一个归档插件会按照年月列表展示文章,所以我在 Hugo 中按照之前的格式实现了一下。按照如下内容新建 layouts/_default/archive.html 文件,并新建文章 content/日志.md,文章内容为空即可,在文章的 meta 数据中指定 layout: archive 来映射到该模板。

{{/* layouts/_default/archive.html */}}
{{ define "main" }}
{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          <script type='text/javascript' src="https://lib.baomitu.com/jquery/1.11.1/jquery.min.js"></script>
          <style type="text/css">.car-collapse .car-yearmonth { cursor: s-resize; } </style>
          <script type="text/javascript">
            /* <![CDATA[ */
              jQuery(document).ready(function() {
                jQuery('.car-collapse').find('.car-monthlisting').hide();
                jQuery('.car-collapse').find('.car-monthlisting:first').show();
                jQuery('.car-collapse').find('.car-yearmonth').click(function() {
                  jQuery(this).next('ul').slideToggle('fast');
                });
                jQuery('.car-collapse').find('.car-toggler').click(function() {
                  if ( '展开全部' == jQuery(this).text() ) {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').show();
                    jQuery(this).text('折叠全部');
                  }
                  else {
                    jQuery(this).parent('.car-container').find('.car-monthlisting').hide();
                    jQuery(this).text('展开全部');
                  }
                  return false;
                });
              });
            /* ]]> */
          </script>
          <div class="car-container car-collapse">
            <a href="#" class="car-toggler">展开全部</a>
            
            <ul class="car-list">
              {{ range (.Site.RegularPages.GroupByDate "01月 2006") }}
              <li>
                <span class="car-yearmonth">{{ .Key }} <span title="Post Count">({{ len .Pages }})</span></span>
                <ul class="car-monthlisting">
                  {{ range .Pages }}
                  <li>
                    {{ .Date.Format "02"}}: <a href="{{ .Permalink }}">{{ .Title }} </a> <!--<span title="Comment Count">(0)</span>-->
                  </li>
                  {{ end }}
                </ul>
              </li>
              {{ end }}
            </ul>
          </div>
        </div>
    </div>
</div>
{{ end }}

统计

屈屈的博客中还有一个统计页面,我觉得挺有意思的,于是也在我的博客中复刻了一下。按照如下内容新建 layouts/_default/stats.html 文件,并新建文章 content/统计.md,文章内容为空即可,在文章的 meta 数据中指定 layout: stats 来映射到该模板。

{{/* layouts/_default/stats.html */}}
{{ define "main" }}

{{- $.Scratch.Add "stats" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "stats" (dict "title" .Title "slug" .Slug "year" (.Date.Format "2006") "month" (.Date.Format "2006-01") "hour" (.Date.Format "15") "week" (.Date.Format "Monday") "count" .WordCount) -}}
{{- end -}}

{{ $hasToc := and (in .TableOfContents "<li>" ) (.Params.toc) }}
{{ $hasSidebar := or ($hasToc) (.Params.series) }}
<style>
.chart {
  margin-top: 15px;
  width: 100%;
  height: 350px;
}
</style>
<div class="grid grid-cols-2 lg:grid-cols-8 gap-4 lg:pt-12">
    <div
        class="col-span-2 {{ if not $hasSidebar }} {{- print "lg:col-start-2" -}} {{ end }} lg:col-span-6 bg-secondary-bg rounded px-6 py-8">
        <h1 class="font-bold text-3xl text-primary-text">{{ .Title }}</h1>

        <div class="flex flex-wrap flex-row items-center my-2 text-tertiary-text">
          <div class="mr-6 my-2">
              <i class="fas fa-calendar mr-1"></i>
              <span>{{ .Date.Format (.Site.Params.dateFormat | default "2006-01-02") }}</span>
          </div>
        </div>
        
        {{ $featured := partial "utils/get-featured" . }}
        {{ with $featured }}
        <div class="my-4">
            {{ . }}
        </div>
        {{ end }}

        <div class="content">
          {{ .Content }}
        </div>
    </div>

    {{ if $hasSidebar}}
    <div class="col-span-2">
        {{ if .GetTerms "series" }}
        {{ partial "components/post-series.html" . }}
        {{ end }}
        {{ if $hasToc }}
        {{ partial "components/post-toc.html" . }}
        {{ end }}
    </div>
    {{ end }}
</div>




<script src="https://lib.baomitu.com/echarts/5.0.0/echarts.min.js"></script>
<script>
const data = {{- $.Scratch.Get "stats" -}};
function showChart(id, title, type, d) {
  var chart = echarts.init(document.getElementById(id));
  var xData = [];
  var yData = [];
  d.forEach(function(item) {
      xData.push(item[0]);
      yData.push(item[1]);
  });
  var option = {
    title : { text : title },
    tooltip : { trigger : 'axis' },
    xAxis : [ { type : 'category', data : xData } ],
    yAxis : [ { type : 'value' } ],
    grid : { x : 35, y : 45, x2 : 35, y2 : 35 },
    series : [ { 
      type : 'bar',
      name : type,
      data : yData,
      markLine : {
        data : [ {
          type : 'average',
          name : '平均值'
        }],
        itemStyle : {
          normal : {
            color : '#4087bd'
          }
        }
      },
      itemStyle : {
        normal : {
          color : '#87cefa'
        }
      }
    }]
  };
  chart.setOption(option);
}

window.addEventListener('load', function() {
  basicInfo();
  yearStats();
  monthStats();
  hourStats();
  weekStats();
});
function basicInfo() {
  const articles = {{ len (where .Site.RegularPages "Section" "posts") }};
  const pages = data.length - articles;
  const comments = data.reduce((count, article) => count + article.comments, 0);
  const words = data.reduce((count, article) => count + article.count, 0);
  document.querySelector('#basic-info').innerHTML = `
    <span>文章:<strong><a href="/">${articles}</a></strong> 篇</span>;<span>页面:<strong><a href="/">${pages}</a></strong> 篇</span>;<span>总字数:<strong>${words}</strong></span>;
  `;
};

function yearStats() {
  const yearGroup = {};
  data.forEach(article => {
    const year = parseInt(article.year);
    if(!yearGroup.hasOwnProperty(year)) {
      yearGroup[year] = 0;
    }
    yearGroup[year] += 1;
  });

  const d = [];
  for(let i = 2009; i <= (new Date().getFullYear()); i++) {
    d.push([i, yearGroup[i] || 0]);
  }
  showChart('year-stat', '文章数 - 按年统计', '文章数', d);
}
function monthStats() {
  const monthGroup = {};
  data.forEach(article => {
    if(!monthGroup.hasOwnProperty(article.month)) {
      monthGroup[article.month] = 0;
    }
    monthGroup[article.month] += 1;
  });
  const d = [];
  for(let year = 2009; year <= (new Date().getFullYear()); year++) {
    for(let month = 1; month < 13; month++) {
      const text = `${year}-${month < 10 ? '0' + month : month}`;
      d.push([text, monthGroup[text] || 0]);
    }
  }
  showChart('month-stat', '文章数 - 按月统计', '文章数', d);
}
function hourStats() {
  const hourGroup = {};
  data.forEach(article => {
    const hour = parseInt(article.hour);
    if(!hourGroup.hasOwnProperty(hour)) {
      hourGroup[hour] = 0;
    }
    hourGroup[hour] += 1;
  });
  const d = [
    ['00:00-01:00'],
    ['01:00-02:00'],
    ['02:00-03:00'],
    ['03:00-04:00'],
    ['04:00-05:00'],
    ['05:00-06:00'],
    ['06:00-07:00'],
    ['07:00-08:00'],
    ['08:00-09:00'],
    ['09:00-10:00'],
    ['10:00-11:00'],
    ['11:00-12:00'],
    ['12:00-13:00'],
    ['13:00-14:00'],
    ['14:00-15:00'],
    ['15:00-16:00'],
    ['16:00-17:00'],
    ['17:00-18:00'],
    ['18:00-19:00'],
    ['19:00-20:00'],
    ['20:00-21:00'],
    ['21:00-22:00'],
    ['22:00-23:00'],
    ['23:00-24:00']
  ].map((item, key) => {
    item[1] = hourGroup[key] || 0;
    return item;
  });
  showChart('hour-stat', '文章数 - 按时段统计', '文章数', d);
}
function weekStats() {
  const weekGroup = {};
  data.forEach(article => {
    if(!weekGroup.hasOwnProperty(article.week)) {
      weekGroup[article.week] = 0;
    }
    weekGroup[article.week] += 1;
  });
  const d = [
    ['星期一', weekGroup.Monday],
    ['星期二', weekGroup.Tuesday],
    ['星期三', weekGroup.Wednesday],
    ['星期四', weekGroup.Thursday],
    ['星期五', weekGroup.Friday],
    ['星期六', weekGroup.Saturday],
    ['星期日', weekGroup.Sunday]
  ];
  showChart('weekday-stat', '文章数 - 按星期几统计', '文章数', d);
}
</script>

{{ end }}

其它

除了以上这些,我的博客中改动最大的当属评论这块,但这块定制型比较高,一般玩家就不推荐了,感兴趣的还是去看我之前的《静态博客如何高性能插入评论》一文。除此之外,我还修改了 footer.html 修改了底部显示文案,增加了网页统计脚本;基于自研的 wxhermit 增加了微信分享自定义相关功能;文章页目录底部增加了个人公众号的展示。由于这些内容都比较简单且定制化内容程度比较高,就不一一展示了,感兴趣的朋友可以自行查看源码查阅。

静态博客如何高性能插入评论

🌏 前言

我们知道,静态博客由于不带有动态功能,所以针对评论这种动态需求比较大众的做法就是使用第三方评论系统。第三方评论的本质其实就是使用 JS 去调取第三方服务接口获取评论后动态渲染到页面中。虽然它很好的解决了这个问题,但是由于需要请求接口,在体验上远比动态博客的直出效果要差很多。所以当我把博客从动态博客 Typecho 迁移到静态博客 Hugo 上来时,就一直在思考这个问题。直到我看到了 Hugo 的 getJSON 方法,发现原来静态博客也是能够像动态博客一样直出评论的。

大部分的静态博客的原理是解析存储内容的文件夹,使用一些模板语言遍历数据生成一堆 HTML 文件。而 Hugo 除了解析 Markdown 内容之外,还支持额外的数据获取方法 getJSON。由于有了 getJSON 方法的出现,我们可以实现在博客编译构建过程中动态的去获取评论接口数据,将其渲染到页面中,实现评论数据的直出效果。关于 getJSON 的更多介绍,可以查看 Hugo 文档数据模板一节。

🎃 方案

高性能方案基本思路是在需要评论数据的地方通过 getJSON 方法调用接口获取评论数据并进行模板渲染。当评论更新的时候,我们需要触发重新构建。实现这个方案依赖三个关键要素:

  1. 构建过程支持调取接口获取数据
  2. 评论服务提供 HTTP 接口返回数据
  3. 博客部署服务支持钩子触发重新构建

我的博客使用的是 Hugo 静态博客系统,如上文所说通过 getJSON 即可解决第一个问题。而我的评论服务使用的是自研的 Waline 评论系统,它提供了评论数、评论列表、最近评论等基础接口满足我们的数据获取需求。并且 Waline 提供了丰富的钩子功能,支持在评论发布的时候触发自第一方法。我的博客部署在 Vercel 上,它提供了 Deploy Hooks 功能,通过 URL 即可触发重新构建。也就是说我只要在 Waline 评论发布的钩子中调用 Vercel 的钩子 URL 触发重新构建即可解决第三个问题。

🥪 实现

我的博客上有三处地方和评论有关,分别是首页侧边栏的最近评论,文章标题下方的评论数,以及文章详情页底部的评论列表展示。

🍞 最近评论

Waline 最近评论接口:文档

{{ $walineURL := .Site.Params.comment.waline.serverURL }}
<h2 class="widget-title ">最近回复</h2>
<ul class="widget-list recentcomments">
  {{ $resp := getJSON $walineURL "/comment?type=recent" }}
  {{ range $resp }}
  <li class="recentcomments">
    <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
  </li>
  {{ end }}
</ul>

🧀 文章评论数

Waline 获取文章对应的评论数接口:文档

{{ $walineURL := .Site.Params.comment.waline.serverURL }}
{{ $count := getJSON $walineURL "/comment?type=count&url=/" .Slug ".html" }}
<a href="{{ .Permalink }}#comments" title="{{ .Title }}">
  <i class="fas fa-comment mr-1"></i>
  <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
</a>

🍯 评论列表

评论列表由于有分页的存在,不像最近评论和评论数一样简单的调用接口即可。先获取评论数,发现有评论时先获取第一页的评论,主要是用来获取总共有多少页评论。之后再从第二页开始循环获取评论数据。最终将获取到的数据全部存到 {{$scratch.Get "comments"}} 数组中,使用模板语法渲染该数组数据即可。

{{$baseUrl := .Site.Params.comment.waline.serverURL}}
{{$slug := .Slug}}
{{$count := getJSON $baseUrl "/comment?type=count&url=/" $slug ".html" }}
{{$scratch := newScratch}}
{{$scratch.Add "comments" slice}}

{{if gt $count 0}}
  {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&page=1&pageSize=100"}}
  {{range $cmt := $comments.data}}
    {{$scratch.Add "comments" $cmt}}
  {{end}}

  {{$totalPages := $comments.totalPages}}
  {{if gt $totalPages 1}}
    {{range $page := seq 2 $totalPages}}
      {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&pageSize=100&page=" $page}}
      {{range $cmt := $comments.data}}
        {{$scratch.Add "comments" $cmt}}
      {{end}}
    {{end}}
  {{end}}
{{end}}

<div class="vcards">
  {{range $cmt := $scratch.Get "comments"}}
  <div class="vcard" id={{$cmt.objectId}}>
    <img class="vimg" src="https://gravatar.loli.net/avatar/{{$cmt.mail}}?d=mp">
    <div class="vh">
      <div class="vhead">
        <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
        <span class="vsys">{{$cmt.browser}}</span>
        <span class="vsys">{{$cmt.os}}</span>
      </div>
      <div class="vmeta">
        <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
        <span class="vat">回复</span>
      </div>
      <div class="vcontent" data-expand="查看更多...">
        {{$cmt.comment | safeHTML}}
      </div>
      <div class="vreply-wrapper"></div>
      <div class="vquote">
        {{range $cmt := $cmt.children}}
        <div class="vh" id="{{$cmt.objectId}}">
          <div class="vhead">
            <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
            <span class="vsys">{{$cmt.browser}}</span>
            <span class="vsys">{{$cmt.os}}</span>
          </div>
          <div class="vmeta">
            <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
            <span class="vat">回复</span>
          </div>
          <div class="vcontent" data-expand="查看更多...">
            {{$cmt.comment | safeHTML}}
          </div>
          <div class="vreply-wrapper"></div>
        </div>
        {{end}}
      </div>
    </div>
  </div>
  {{end}}
</div>

🍳 构建触发

Waline 在评论发布、更新和删除阶段都支持自定义钩子,在钩子中触发 Vercel 的构建钩子即可完成发布评论重新构建的流程。

按照如下内容修改服务端部署的 index.js 文件,查看文档了解全部的 Waline 钩子。

const Waline = require('@waline/vercel');
const https = require('https');
const buildTrigger = _ => https.get('https://api.vercel.com/v1/integrations/deploy/xxxxx');

module.exports = Waline({
  async postSave(comment) {
    if(comment.status !== 'approved') {
      return;
    }
    buildTrigger();
  },
  async postUpdate() {
    buildTrigger();
  },
  async postDelete() {
    buildTrigger();
  }
});

🍾 后记

通过以上操作,就能在不损失用户体验的情况下实现评论数据的动态支持了。有些人可能会担心是否会在构建阶段造成超多的接口请求。这里大可不用担心,Hugo 自己会在构建的时候做接口的缓存,同 URL 的接口调用会走缓存数据而不会重新调用。

除了用户体验之外,由于只会在构建的时候触发数据的获取,针对有调用次数配额的第三方评论服务也能节省额度。当然,理论上构建次数是远小于访问次数的,所以额度节省的结论是能成立的。如果说你的构建次数要比访问次数还要大的话,那这种方法就无法节省额度了。

当然这种方式也会有带来些问题,主要是评论的更新没那么快。好在 Hugo 的构建速度非常快,一两分钟的时间也能接受。而针对用户评论的发布,则可以通过评论发布后先假插入缓解该问题。

Hugo 之旅

之前写了篇文章《博客迁移至 Hugo》,提了下使用 Typecho 多年后越发感受到运维的成本之高后,将博客迁移到了静态博客程序 Hugo 下。使用 Vercel + Github 可以免费搭建高性能博客,绑定域名还能自动帮忙创建 SSL 证书。当然偷懒的话也可以直接使用默认分配的二级域名。

搭建

创建 Hugo 博客

点击上面的按钮快速抵达创建页面,未登录的会需要登录,这块直接使用 Github 登录即可。登录后第一步会让你选择 Vercel 账号,这里直接选择 Personal Account 即可。之后会让你输入仓库名称,Vercel 会自动帮你创建并初始化该仓库。如果你的仓库不想让其它人看的话,这里可以勾选 Private Git Repository 创建私有仓库。

下一步这块会让你输入 Vercel 中项目的名称和一些配置。这里需要注意一下,官方提供的默认 Hugo 编译命令会把草稿文章也生成出来。需要在 BUILD COMMAND 那打开 OVERRIDE 按钮后输入 hugo --gc 进行覆盖。

稍等片刻之后,你就可以看到飘着满屏的彩带庆祝你创建博客成功叻!点击 Visit 按钮你就可以看到你的博客的样子了。

由于是静态博客,你所有的文章都会存储在你刚才新建的仓库中。你可以选择将仓库下来修改后提交,也可以利用 Github 的在线编辑功能在线修改提交。提交之后 Vercel 会自动触发更新,重新构建并更新你的博客。

配置

默认每次提交 Vercel 构建完成之后都会把构建后的地址评论在你的 Github 提交下。你可以通过设置关闭该功能。另外默认 Vercel 指定的 Hugo 版本比较老了,在 Markdown 编译过程中会发生一些异常的行为。我们可以通过配置指定最新的 Hugo 版本进行编译。在项目根目录下新建 vercel.json 文件,并加入以下内容。

{
  "github": {
    "silent": true
  },
  "build": {
    "env": {
      "HUGO_VERSION": "0.78.1"
    }
  }
}

Hugo 博客本身的配置都在 config.toml 下。默认情况下 Hugo 生成的 URL 都是 /posts/hello-world/ 这种格式,不过之前做动态博客为了做伪静态,一般都将路由设置成了 /hello-world.html 这种格式。这种时候就需要在 config.toml 中增加 uglyurls 配置。

uglyurls = true
[permalinks]
  posts = ":slug"

后面的 permalinks 配置主要是用来去除文章的 /posts 前缀的。除此之外,默认的配置文章中的 HTML 是会被转义的,对于我这种偶尔会在 Markdown 中写 HTML 的人来说操作有点多余。这时候可以在配置中定义它不转义。

[markup.goldmark.renderer]
  unsafe= true

域名

使用 Vercel 搭建的网站,它会默认提供一个 *.vercel.app 的二级域名,你可以直接使用这个域名访问网站。如果你想要绑定自己的域名,也可以在后台设置。进入网站后选择自己的项目,选择 Settings - Domains 进入域名配置界面,在输入框中添加自己的域名。它会提示你需要给域名增加 A 记录或者是 CNAME 解析。按照提示添加后后台会自动检测是否生效。

生效的时间视 DNS 服务器的生效时间而定,我这边使用 DNSPod 还挺快的,大概 30 秒之内就生效了。生效后 Vercel 会自动帮我们申请配置 SSL 证书,我们全然不用操心证书的问题。等待片刻之后我们就能直接使用新域名进行访问了。

图片

默认所有的静态资源都放在 static/ 目录下。你可以将图片放在该目录下,例如 static/hello-world.jpg。在文章中则直接使用 /hello-world.jpg 地址引用即可。

由于 Github 仅有单文件小于 100M 的限制,Vercel 会将所有的资源部署到自己服务器上。所以使用仓库存储的方式会非常方便和安全,而且还不损失速度。唯一美中不足的是,由于是 HTTP 路径,在本地编写文章的时候会不方便。如果是使用 Typora 的话可以点击标签栏 格式 - 图像 - 设置图片根目录 将目录指定到 static/ 目录解决。VSCode 的话暂时没有倒腾出来。

其实比较好的方案是建议大家创建一个文件夹,将该篇文章和它所用到的图片都归置到一块。使用类似组件化的思路管理内容,会更加方便后续的修改。

除了使用本地存储之外,你也可以选择使用第三方的存储服务。免费服务的话可以试试 又拍云 提供的联盟计划,按照要求申请下即可。其他家的存储服务要么免费时间有限,要么有部分收费功能。第三方存储的好处在于带有 CDN 加速,在速度上会很方便。但目前 Vercel 的速度我觉得还挺不错的,所以最终还是选择了直接存仓库。

评论

使用静态博客之后,评论则只能选择第三方评论系统了。鉴于国情,国内的第三方评论服务都已名存实亡。目前比较知名的 Disqus , HyperComments 以及 来必力 都是国外的服务。HyperComments 是付费的服务就不多做讨论,Disqus 是老牌服务,提供了强大的 API 对开发者较为友好。来必力则是对国内用户友好,集成了很多国内的社交账号登录。

除了专门提供评论的第三方服务之外,也有一部分是选择自建评论服务。其中比较知名的是 Valine 和 Gitalk 以及 Staticman,isso。Staticman 提供了强大的 API 但是缺少界面,isso 则是需要服务器部署,偏离了我使用静态博客的初衷就是不想维护服务器的目的,所以这两者都不多做讨论。Gitalk 是使用 Github issue 进行评论数据存储的评论脚本,适合纯技术博客和极客使用。Valine 则是基于LeanCloud Serverless 云存储进行评论数据存储的评论脚本,同时它还带有漂亮的外观,适合各类人群使用。

博客站点上与评论相关的地方一般有三个地方:

  1. 首页显示最近评论
  2. 文章下显示当前文章评论数
  3. 文章底部显示该文章的评论列表以及输入框

最开始我选择了 Disqus 作为本站的评论系统。但是它有个问题国内正常无法访问。后来我发现 Hugo 的模板中是可以使用 getJSON 方法调取接口的。而 Vercel 的机器本身就在海外,那我实际上可以在博客编译阶段就获取到输入写到页面中。最近评论和评论数的显示还是比较简单的,而评论列表的显示则需要折腾一下了。不过 Disqus API 的使用频率是 1000 次/小时,当短时间部署频繁的话可能会有超过的风险。而且发布评论的接口没办法走该逻辑,还是需要一个代理服务。

最终让我决定不使用 Disqus 的原因是它的评论没办法让评论者输入网站地址,评论列表中的评论者昵称点击也无法跳转到用户输入的网站中。这对于有着大量的老式评论数据的我来说是不太能接受的。所以基于这些种种原因,我最后又将评论迁移到了 Valine 下。

我对 Valine 的主要问题在于两点:

  1. 早前使用 Valine 的时候发现了大量的 XSS 漏洞
  2. 荒野无灯大大反馈的“泄露用户隐私问题”

以上两个是非常重要的安全问题,不过我看 XSS 的问题作者已经修复,实际上对用户输入的所有 HTML 内容进行转义可以规避该问题。而灯大反馈的 IP 泄露的问题,作者也通过不记录 IP 来规避该问题。这两个问题造成的原因其实还是因为 Serverless 。因为我想直接在编译的时候将相关数据静态化,所以我势必是需要增加一个服务端来帮我进行接口的封装,最后也能规避这些问题。

不过 Valine 不知因为什么原因不开放源码,只在 Github 提供编译后代码,这点除了让我比较无语之外,改造工作也比较慢。本来我只需要将它 UI 中调用 LeanCloud API 的逻辑替换成我的接口逻辑即可。但是因为没有源码,只能自己重新制作 UI 了。

更新:基于 Valine 衍生的带后端评论系统已经完成 https://github.com/lizheming/waline 已切换至该评论系统。2020/11/08

搜索

缺少服务端之后,静态博客的搜索功能也无法自己完成。目前比较知名的是第三方搜索服务 Agolia,提供了免费的文章索引和搜索的功能。除了使用第三方服务之外,还发现有一种比较简单的做法就是将所有数据生成到一个文件中,前端下载该文件进行搜索结果展示。这里头比较知名的是 fuse,它是一个 JS 模块,将输入传入之后它能帮我们快速的匹配到命中结果。

目前本站使用的是后一种方法,该方法的优点是不依赖第三方服务,自己就能完成搜索功能。缺点是第一次搜索的时候需要下载完整的数据,对文章比较多的网站用户鸭梨会比较大。使用第二种方法第一步是需要先创建全量的数据索引,通过以下配置告知 Hugo 编译时创建 index.json 索引文件。

[outputs]
  home = ["HTML", "RSS", "JSON"]

当然我们还需要为新的数据编写生成的模板文件,在 layouts/ 下增加 index.json 文件,并加入以下内容:

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "contents" .Plain  "summary" .Summary "permalink" .Permalink "date" (.Date.Format "2006年01月02日")) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

其中 dict后的字典可以添加多个字段,视你 JS 渲染脚本中需要的字段而定。完成这些之后 Hugo 编译就会生成 index.json 文件了。

最后我们需要增加一个搜索页面,该页面会先加载 index.json 然后使用 fuse 进行数据查找,最后渲染成 HTML 输出搜索结果。具体的代码可以直接查看源码 https://imnerd.org/search.html?q=hugo 参考本站的搜索页。

//以下为示例代码
let fuse;
async function search(text) {
  if(!fuse) {
    const indexData = await fetch('/index.json', {method: 'GET'}).then(resp => resp.json());
    fuse = new Fuse(indexData, {...});
  }
  const result = fuse.search(text);
  renderHTML(result, '#app');
}

其它

Hugo 静态博客能提供给我们发挥的空间非常多,比如说我参考 屈屈的博客 增加了一个博客文章发布 >180 天的话,就在文章详情页黄条提醒:

提醒:本文最后更新于 313 天前,文中所描述的信息可能已发生改变,请谨慎使用。

正常来说由于我没有服务端,所以只能使用前端来计算当前时间与发布时间的时间差。但其实转念一想,编译时也是可以拿到时间差的,只是这个时间差无法随着时间的变化而变化。不过我只要每天编译一次,就能解决变化的问题了。在 Vercel 项目 Settings - Git - Deploy Hooks 中你可以输入 Hook 名称和触发分支创建一个 Hook URL。只要访问该 URL 就可以触发 Vercel 更新博客。

{{ if eq .Type "posts" -}}
	{{ $ageDays := div (sub now.Unix .Date.Unix) 86400 }}
	{{ if gt $ageDays 180 }}
		<p class="expired-tips"> 提醒:本文最后更新于&nbsp;{{$ageDays}}&nbsp;天前,文中所描述的信息可能已发生改变,请谨慎使用。</p>
	{{ end }}
{{ end }}

而在 Vercel 的 Marketplace 中,有一个 EasyCron 的服务提供了定时任务的功能。我在上面设置了每天零点访问 Hook URL 触发博客更新,就这样解决每天需要更新时间的问题。

另外屈屈博客中的“查看本文Markdown版本”也是个不错的功能,在 Hugo 中也可以实现,本质是在编译的时候顺便生成一份 .md 的 Markdown 文件即可。

首先我们需要在 layouts/ 下增加 single.md 文件,表示的是当文章页(Single)需要导出 .md 的数据的时候使用该模板。模板内容不用填写,为空即可。然后在 config.toml 中新增 .md 文件类型,并告知 Hugo 文章需要增加编译 Markdown 文件类型。

[mediaTypes]
  [mediaTypes."text/plain"]
    suffixes = ["md"]

[outputFormats.MarkDown]
  mediaType = "text/plain"
  isPlainText = true
  isHTML = false

[outputs]
  page = ["HTML", "MarkDown"]

Typecho 评论导出至 Valine 插件

前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。文章我基于前人的脚本自制了 Export2Hugo 插件进行导出,但是发现评论居然没有相关的同学折腾过。大部分都是使用了 ByeTyp 和 TypExport 插件将数据导出成 WordPress ,然后再通过 Disqus 桥接到 Valine,非常的麻烦。

Valine 使用的 LeanCloud 存储服务是支持文件导入的,于是我写了个 Node.js 脚本将 Typecho 数据导出成了支持导入的文件格式完成了导入。基于该脚本的逻辑我将其转成了 Typecho 插件 Export2Valine 方便大家迁移。

插件使用非常简单,启用插件后在左上角 控制台 菜单中会新增 评论导出 选项。点击后直接点击导出 LeanCloud JSON 文件按钮稍等片刻就可以拿到导出的 JSON 文件了。

在 Leancloud 后台新建应用后,选择 存储-导入导出-数据导入。Class 名称输入Comment,数据文件选择刚才导出的 JSON 文件。点击导入按钮即可等待导入成功。导入的时间会随着评论数的增加而增加,如果评论数比较多的请耐心等待提示。

这里需要注意两点:

  1. LeanCloud 应用中如果已经存在了 Comment 表的话需要删除,否则数据不会导入。

  2. 插件导出的评论对应的文章 URL 格式为 /{slug}.html,为了能让文章和评论数据正确映射,需要将你的文章设置成对应的格式才能正确显示评论。以 Hugo 为例,在 config.toml 中需要配置:

    uglyurls = true
    [permalinks]
      posts = ":slug"
    

下载地址:Github

Typecho 迁移至 Hugo 插件

前几天我为了降低成本把博客迁移到了 Hugo,具体可见《博客迁移至 Hugo》。在迁移的过程中找到了 @eallion 的这篇文章《Typecho 迁移到 Hugo》 ,里面使用一个 PHP 脚本将数据导出成 Markdown 文件存储到本地。当时使用了下发现有这么一些不足的地方:

  1. 因为不是插件,所以需要人工编辑配置数据库信息
  2. 脚本会把所有的 Markdown 文件生成在 Typecho 根目录下
  3. 会导出一些附件信息等额外的文件

所以为了让导出更加方便,我参考了 panxiahai/TypExport 该插件将之前的脚本整理成了插件 Export2Hugo。插件会直接将文件按照 Hugo 的格式存储后以 ZIP 压缩文件的形式下载到本地。

使用方法很简单,启用插件后在左上角 控制台 菜单中会新增 导出至Hugo 选项。点击后直接点击开始导出!按钮稍等片刻就可以拿到导出的 ZIP 文件了。

需要注意 Mac 下可能双击无法打开该压缩文件,可以在终端试试使用 unzip 命令进行解压。

下载地址:Github

博客迁移至Hugo

我的博客从09年开始到现在已经度过了十一个年头,虽然更新的频率变慢了,但也还是一直在坚持输出力求能够帮助到大家。本站最早使用过 ASP 的 PJBlog 到之后大名鼎鼎的 WordPress,之后在 2011年 的时候迁到了 Typecho 一直到现在。

因为多年的数据和主题修改沉淀,我一直懒得更换程序,甚至是自己开发的 Node.js 博客系统 Firekylin 也没有激发我把主站迁移过去。不过随着网络环境的不稳定性,我也在慢慢寻找一些比较靠谱的备份和快速迁移的方式来降低博客的运维成本。

数据库和Docker

现在最方便的部署方式就是容器了,所以我第一个想到的就是将博客容器化。因为 Typecho 可能会有图片上传等用户数据产生,所以比较合理的镜像方式是为 Typecho 程序制作镜像,同时将用户数据挂载到本地。

数据库则选择购买了阿里云专门的数据库服务,降低数据库这块的人工维护成本。这样维护的成本就变成了只要定时备份用户上传文件即可。但是随之又带来了新的问题。

数据库服务贵不好用

首当其冲的就是数据库服务的成本问题,最低配版套餐都要好几百,基本等同于一台主机的价钱。人工运维成本是降低了,但是金钱运维成本直接加倍!而且跨主机商只能使用外网连接,虽然都是同地区服务,但是连接意外的慢。导致主站打开需要好几秒,大大降低了用户体验。

机器性能要求高

然后容器对机器的性能也有一定要求。我一直在使用 1核2G 的低配机器维护着我的网站。由于我维护的站点比较多,使用上容器之后发现机器扛不住。换成了 2核4G 之后好一点了,但是金钱运维成本再次加倍!

镜像构建困难

最后其实是容器构建这块的成本。因为手上很多的老站都是使用 PHP 开发的,之前只是单纯的用这些程序。现在为了制作镜像,需要去看程序的基础依赖。

另外还有一些老程序是破解版的,不太适合放 Docker Hub 上,寻找一个可以让我托管私有镜像的地方也废了我不少时间。之前一直没关注过这块,很早之前知道的几个做容器服务的要么不做了,要么就是改成收费了。后来发现其实 Github 就能直接托管镜像,这才方便了点。

总的来说容器的虽然降低了迁移的成本,但是目前看来性价比不高,想要更好的服务势必要投入更多的资金才行,对我来说有点舍本逐末。

静态部署

同时为了降低我的其它站的运维成本,考虑之后决定将一些不使用的站进行静态博客处理。保留了存档的功能,同时降低了维护成本。主要是现在有很多的免费服务支持静态网站部署。比如我之前在用的 Vercel,很好的提供了对静态博客的支持。在挑选程序的时候发现 Hugo 上我现在在用的主题已经有人实现了 TwentyFourteen,这个主题是我很早之前从 WordPress 的默认主题上迁移过来的。性能非常好。遂就使用它将其他的一些站进行了迁移。

只需要在 Vercel 上使用 Import Template 的形式创建项目即可。轻轻一点它便会自动帮你创建 Github 仓库,分配域名,添加证书,最后自动部署上线。然后我再把仓库克隆下来,把导出的旧数据挪进仓库提高一下即可。Vercel 会自动触发更新的流程。

不管是人力成本,金钱成本,还有数据备份成本上来看,这种方案都达到了最低方案。特别在 Github 给个人开放了无限的私有仓库之后,你连数据隐秘性都不需要考虑了。所以在迁移了四五个老站之后,我看着剩下来的主站,决定一不做二不休一块迁移得了。

后记

关于如何进行迁移我之后再单独写一篇文章来讲。迁移之后运维成本直接消失,不仅构建快而且页面访问速度也快,一个字香!之前 Typecho 的 Markdown 格式一直不支持表格语法,换了之后没有这些问题了。不过也不是说没有缺点,之前其实用 Typecho 的后台写文章非常方便,现在换成 VSCode 之后在快捷方式上没有网页的好用,之后再考虑做个单独的发布页吧。

Firekylin 如何自定义首页

之前用 WordPress 和 Typecho 的时候有个功能特别好用,那就是自定义首页。用户可以将某个页面指定成首页,而页面又可以通过自定义模板来个性化。最终达到了个性化首页的目的。这么棒的功能当然 Firekylin 也要支持啦,下面就和大家说说在 Firekylin 中如何实现个性化首页功能。

页面自定义模板

0.15.0 版本新增自定义模板的功能,将自定义模板放置在主题目录的 template 文件夹下,程序就会识别该自定义模板。模板的内容随意,可以是纯静态的 HTML 内容,也可以是 nunjucks 语法。后台新建页面右侧可以选择你设定的自定义模板,选择该模板之后程序就会使用它替换默认的 page.html 模板渲染该页面。

选择自定义模板

自定义网站首页

设置好自定义页面之后,在 设置-阅读设置 中可以选择某个页面作为首页。这样就完成了自定义首页的配置。设置了页面为自定义首页之后如果想要再次访问原来的首页,可以使用 xxx.com/post/list 地址来访问。

选择自定义页面

自定义错误页

是不是已经对 404 页面枯草的报错感到厌烦了?0.15.0 同时增加了自定义错误页的功能。在主题目录的 error 文件夹下,放置对应错误号的模板程序就会识别以该模板渲染错误页。例如 www/theme/firekylin/error/404.html 即是自定义 404 页面文件路径。

有了这些自定义功能,我相信爱折腾小伙伴的博客会越来越漂亮了。

Typecho在线升级插件 - Update 0.0.2

是不是已经厌烦了Typecho每次升级还得开FTP覆盖文件了呢?好消息来了!为了解决这无比蛋疼的问题,Update插件正式上线了!没有复杂的配置没有复杂的选项,只要开启插件就行了!只要开启插件就行了!(等下,一股浓浓的电视购物风是什么情况Σ(っ °Д °;)っ)
当程序检测到新版的时候会在控制台首页增加一个链接,点击更新就可以了!一击解决Typecho多年蛋疼问题!

DEMO

另外比较温馨的是程序会备份Typecho程序目录下的所有文件(备份文件夹位于插件目录下的backup文件夹),所以你也不必担心升级扑街神马的,如果失败了再把备份文件还原一下就好啦!yeah,就是这么简单!

p.s. 我是不会和大家说为了攒这个插件忍着一个月没升级我的Typecho的→_→...


鉴于有一些人因为服务器文件夹权限等问题导致更新失败,本插件更新至 0.0.2 版本,为每一个过程增加了较为详细的错误信息提示。

下载地址:Box | 百度云

移植主题TwentyFourteen

唔,前两天有人求WordPress主题,交流一番之后知道了WordPress居然又发新的默认主题了!之前那款TwentyTwelve也很是不错的说,看了下新发的TwentyFourteen,我也很是喜欢,果断就开始了扒皮行动。话说我也是现在才知道WordPress的默认主题对应着年份的,这么后知后觉真心好忧桑。至于TwentyThirteen那款默认主题由于个人不喜欢甚至觉得有些难看而自动过滤掉了。

在跟各种神奇的WordPress语法大干一场后,抄袭移植就基本完成了。秉承着尽量忠于原主题的原则(说的好像自己扒皮多有原则似的,→_→),主题的资源文件当然是毫不犹豫的是从原主题拷贝过来的啦。发现WordPress现在真是各种高度集成,乱七八糟什么样的函数都有,通过函数能做大部分的事情。同时今天看到一句话“Typecho就是早期整合度不高的WordPress”,对此也深表同意。原来做WordPress主题跟Typecho一样,也就是header index footer sidebar post page comments functions几个文件而已。现在的WordPress主题动不动就是十几个模板文件,各种嵌套引用,头都大了。以及觉得Typecho果然还是有不足的地方啊,好几个需求都没办法直接实现,有些甚至没法实现,只能各种“黑魔法”。

虽然花了我一些时间去移植,但是不保证完全没有问题啦,有问题的同学请留言提出来,我会尽我所能解决的。最后放上页面预览和下载地址:

TwentyFourteen Theme

更新:

本主题更新至1.1版修复了WP官方的定宽(最大只能显示1260px)的问题,以及搜索页面的一些排版问题。

下载地址:BOX | 百度云(提取码:hvkx)