如何为 Hugo 静态网站添加评论功能

2023-12-13
标签: HUGO NGINX PYTHON

我的网站使用 Hugo 编译静态页面。在此基础上,支持了搜索和评论等动态内容。我打算分两篇文章介绍具体的实现方法,分享给读者。这是第二篇。更新:网站现在已经更换为对象存储,评论功能已经成为历史,但是介绍的方法依然是可行的。

本文先介绍评论功能的实现方法,还有 另一篇文章 介绍如何添加搜索。

效果预览

搜索效果如下:

先决条件

关于搭建静态网站的做法,我在 这篇文章 中有简单介绍。要在静态网站添加动态内容,有如下一些条件:

  • 自己掌握的云服务器
  • 反向代理(比如nginx)
  • 数据库(比如 mysql 或 redis )

具备以上的条件后,我们接下来可以了解下流程。相比搜索功能,增加评论的步骤会简单一些。

基本原理

基本的流程是:

  1. 设计一个用于搜索的路由名称,不能跟 hugo 项目已有的静态路由冲突,比如我用的 /comment/
  2. 在 Hugo 项目的每篇文章页面模板中增加评论的列表和表单
  3. 在 Hugo 项目中,在每篇文章页面中加在 JavaScript 文件,用于向后台请求评论内容。
  4. 建立后台搜索服务,从数据库中提取评论内容。
  5. 在 nginx 配置中,将来自 /comment/ 的请求,分发给后台搜索服务

简单的部署图如下:

下面分步骤介绍实现方法:

制定路由

路由的名称不能和 Hugo 项目的 content 目录下的内容重复,会被 Hugo 和 nginx 同时使用。我这里使用/comment/.

创建评论列表和表单

页面底部的评论列表效果如下:

( ☝评论列表效果 )

评论列表和表单的 HTML 代码例子如下:

<h2>精彩评论</h2>
<div class="border-0 divide-y mb-6" id="comment-container"></div>
<div class="border-0 w-full pt-4" id="comment">
    <textarea class="text-sm my-1 border-1 divide-y leading-6 border border-gray-400 w-full p-2" id="comment-content" name="comment-content" placeholder="必填" minlength="1" maxlength="1000" rows="5"></textarea>
    <div class="flex w-full flex-row flex-nowrap justify-between items-end md:justify-start md:items-center ">
        <div class="my-2 ">
            <label class="mr-1" for="comment-nick">称呼</label>
            <input class="w-40 md:w-auto border border-gray-300 px-1" id="comment-nick" placeholder="必填" name="comment-nick" autocomplete="on" maxlength="25">
        </div>
        <div class="mx-4" id="comment-prompt">
            <button id="comment-submit" style='color:rgba(200,200,200,1)' disabled class="border border-gray-400 justify-self-stretch rounded border w-24 px-2 my-2 hover:text-eureka">提交</button>
        </div>
    </div>
</div>

注意上面的代码中,id 为 comment-containerdiv 是评论列表,将来由 Javascript 请求回来并填充;id 为 commentdiv 则是提交评论的列表。

为了维护方便,上面这段代码不必直接放入文章页面的模板内,而是单独作为一个 partial 保存,例如保存在 layouts/partials/components/comment.html 文件里,再由单个文章页面的模板引用。

单个文章页面的模板文件在 Hugo 项目的 layouts/_default/single.html。编辑这个文件,在相关位置引用上面的 comment.html

{{ with .Page.Params.comment }}
  {{ partial "components/comment" . }}
{{ end }}

.Page.Params.comment 参数是在 Frontmatter 里增加的一个开关,用于控制当前这篇文章是否启用评论功能。

此处省略页面元素的样式属性,因为这取决于各个网站的视觉风格。

这里没有使用 <form> 表单语法,而使用 Vanilla Javascript 提交评论内容

页面发起请求

新建一个 JavaScript 文件,不妨命名为 comment.js ,放在 static 目录下。因为评论列表位置比较靠后,我希望在页面元素全部加载完后再执行这个 js 文件,所以放在 </body> 之前,具体位置是 layouts/_default/baseof.html 文件,增加如下语句:

{{- if .IsPage }}
    {{- $assets := .Site.Data.assets }}
    <script defer src="{{ $assets.base64.js.url }}"></script>
    <script defer src="{{ $assets.comment.js.url }}"></script>
{{- end }}

$assets 变量是定义在 data/assets.yaml 中,用于单独保存 js 文件名,效果如下:

base64:
  js: 
    url: /js/lib/base64.js

comment:
  js:
    url: /js/lib/comment.js

base64.js 文件用于压缩加密 URL 使用,让 URL 更简洁。

comment.js 文件的关键内容有:


var btn = document.getElementById('comment-submit');

// 请求评论内容
function main(){
    // 将提交按钮绑定为发送评论
    if (btn !== null){
        btn.addEventListener('click', function(){
            var data = {
                path: window.location.pathname.trim(),
                nick: nick.value.trim() || '',
                content: content_area.value.trim(),
            }
        
            var r = new XMLHttpRequest();
        
            r.addEventListener('load', function () {
                var result = JSON.parse(r.responseText)
                var prompt = document.getElementById('comment-prompt');
                if(result.status === 'ok' && prompt !== null){
                    prompt.innerHTML = '感谢您的评论,将在审核后公布'
                }
            });
            r.open("POST", "/comment/", true);
            r.setRequestHeader('content-type', 'application/json')
            r.send(JSON.stringify(data));
            btn.setAttribute('disabled' ,'')
            hide(btn);
        })
    }

    // 加载评论列表
    if(window.location.pathname.length > 0){
        var r = new XMLHttpRequest();
    
        r.addEventListener('load', function () {
            var result = JSON.parse(r.responseText)
            if(result.status === 'ok'){
                populate(result)
            }
        });
        r.open("GET", "/comment/"+BASE64.urlsafe_encode(window.location.pathname), true);
        r.send(null);
    } 
}

function populate(result){
    var comment_container = document.getElementById('comment-container');
    var divider = times('-', 100);
    if (comment_container !== null){
        // 显示评论列表
    }else{
        comment_container.innerText = '暂无评论,欢迎您在下方留言'
    }
}

window.onload = main

接下来开始处理后台数据,包括评论的保存和读写处理。

设计数据库

要实现高效的搜索,需要使用数据库。这里以 mysql 数据库为例。

安装好 mysql 后,需要首先创建库和表。为了保存搜索结果的来源信息,数据表需要存储文章的所属 section、标题 等信息。

所以表格设计如下:

+----+---------+---------------------------------+--------------------------+--------------+---------------------+--------+
| id | section | title                           | content                  | nick         | time                | state  |
+----+---------+---------------------------------+--------------------------+--------------+---------------------+--------+
|  2 | tech    | pull-docker-images-behind-proxy | 找了好久,终于找到想要的     | tony         | 2023-01-09 15:55:27 | show   |
+----+---------+---------------------------------+--------------------------+--------------+---------------------+--------+

section 和 tech 字段用于拼接文章的链接,title 字段用于显示,content 是文章的内容,用于搜索。

建立后台评论读写服务

简单的方案使用 Flask 框架建立服务,这个话题可以另开一篇,这里只列出视图函数最关键的部分:


# comment 是个蓝图,公共前缀是 /comment

# 读取评论条目
@comment.route('/<string:code>', methods=['GET'])
def index(code):

    # 省略变量准备和异常处理的部分
    c = Comment.query.filter_by(section=section, title=title).all()
    comments = [
        {
        'id':x.id, 
        'content':x.content, 
        'nick':x.nick,
        'time':datetime.datetime.strftime(x.time, '%Y-%m-%d %H:%M')
        } for x in c if x.state == 'show']
    resp = {'output': comments, 'status':'ok'}
    status_code = 200
    return jsonify(resp), status_code

# 写入评论条目

@comment.route('/', methods=['POST'])
def add_comment():
    # 省略变量准备和异常处理的部分
    c = Comment(
            section=section,
            title=title,
            nick=nick, 
            content=content, 
            time=datetime.datetime.now()
        )
    db.session.add(c)
    db.session.commit()
    resp = {'output': repr(c), 'status':'ok'}

    return jsonify(resp), status_code

另外再配合安装 gunicorn 和 supervisord 就可以开启 web 服务,这里假设 web 服务开在 8888 端口。

配置 nginx

需要配置 nginx ,将 /s/ 的请求转发到后台 web 服务上,而其余的请求仍然解释为静态文件:


location ~ /search/ {
        proxy_pass http://127.0.0.1:8888;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

这样就实现了 Hugo 静态内容配合使用的动态评论功能。

如果您对本站内容有疑问或者寻求合作,欢迎 联系邮箱邮箱已到剪贴板

标签: HUGO NGINX PYTHON

欢迎转载本文,惟请保留 原文出处 ,且不得用于商业用途。
本站 是个人网站,若无特别说明,所刊文章均为原创,并采用 署名协议 CC-BY-NC 授权。