I was looking for a way to host my comments on GitHub Issues when I stumbled upon Paul Knopf’s blog post where he was able to generate his blog post’s comments server side. Since I didn’t want to use client-side scripting to render and post comments or rely on a GitHub App like Utterances, I decided to adapt his approach to my Jekyll based blog.
Note: His implementation was done before GitHub Actions matured, so I was able to take it a step further and have GitHub trigger the Netlify build hook for my blog any time a comment is created, edited, or deleted.
Add Blog Post Front Matter
In each blog post’s front-matter, add a GitHub Issue ID field that points to the comment thread for that page.
comment_issue_id: 3
Convert GitHub API to HTML Snippets
Then loop through each posts front matter, using Octokit
to query the issues comments and auto-generate HTML snippets in the
_includes/comments
directory based on the issue’s number.
In this example script
the comments are styled using Tachyons, but it is
possible to customize how the HTML is generated. This script allows for comments
to be generated for all posts, ruby comments.rb
, or for a single issue based on
its number, ruby comments.rb 3
.
If your build system supports environmental variables, you can securely store your GitHub Personal Access Token
in ENV["COMMENTS_TOKEN"]
to prevent it from being stored in plain text.
Note: The GitHub API rate limiting allows for 60 unauthenticated requests per hour, so the script will run locally, even with
ENV["COMMENTS_TOKEN"]
being unset.
require 'octokit'
require 'yaml'
require 'kramdown'
repo = 'jdillard/blog-comments'
issue = ARGV[0]
# Set access token
client = Octokit::Client.new(:access_token => ENV["COMMENTS_TOKEN"])
# Configure client
client.auto_paginate = true
def create_comments(client, repo, issue_id)
File.open("source/_includes/comments/" + issue_id.to_s + ".html","w") do |f|
if client.issue(repo, issue_id).comments > 0
# Loop over comments and write out comment file
client.issue_comments(repo, issue_id).each do |comment|
# count reactions for each comment
reactions = Hash.new
client.issue_comment_reactions(repo, comment["id"]).each do |reaction|
if reaction["content"] === "+1"
reactions.key?("plusOne") ? reactions["plusOne"] += 1 : reactions["plusOne"] = 1
end
if reaction["content"] === "-1"
reactions["minusOne"] ? reactions["minusOne"] += 1 : reactions["minusOne"] = 1
end
if reaction["content"] === "laugh"
reactions["laugh"] ? reactions["laugh"] += 1 : reactions["laugh"] = 1
end
if reaction["content"] === "hooray"
reactions["hooray"] ? reactions["hooray"] += 1 : reactions["hooray"] = 1
end
if reaction["content"] === "confused"
reactions["confused"] ? reactions["confused"] += 1 : reactions["confused"] = 1
end
if reaction["content"] === "heart"
reactions["heart"] ? reactions["heart"] += 1 : reactions["heart"] = 1
end
if reaction["content"] === "eyes"
reactions["eyes"] ? reactions["eyes"] += 1 : reactions["eyes"] = 1
end
if reaction["content"] === "rocket"
reactions["rocket"] ? reactions["rocket"] += 1 : reactions["rocket"] = 1
end
end
f << '<div class="comment flex flex-row">'+"\n"
f << ' <div class="mr3">'+"\n"
f << ' <a href="' + comment["user"]["html_url"] + '" target="_blank">'+"\n"
f << ' <img class="br2 mw3" src="' + comment["user"]["avatar_url"] + '"/>'+"\n"
f << ' </a>'+"\n"
f << ' </div>'+"\n"
f << ' <div class="flex relative flex-column ba b--light-gray br1 mb3 w-100">'+"\n"
f << ' <div class="f6 ph3 pv2 bg-near-white bb b--light-gray gray">'+"\n"
f << ' <a href="' + comment["user"]["html_url"] + '" target="_blank" class="mid-gray b link">' + comment["user"]["login"] + '</a> commented on'+"\n"
f << ' <a href="' + comment["html_url"] + '" target="_blank" class="gray link">'+"\n"
f << ' <relative-time datetime="' + comment["created_at"].to_i.to_s + '">'+"\n"
f << ' ' + comment["created_at"].strftime("%B %d, %Y") + "\n"
f << ' </relative-time>'+"\n"
f << ' </a> '
comment["updated_at"] === comment["created_at"] ? f << "\n" : f << "(edited)\n"
f << ' </div>'+"\n"
f << ' <div class="body pv2 ph3 black-70">'+"\n"
f << ' ' + Kramdown::Document.new(comment["body"], :input => 'GFM', :syntax_highlighter => 'rouge').to_html+"\n"
f << ' </div>'+"\n"
f << ' <div class="bt b--light-gray gray">'+"\n"
f << ' <ul class="flex flex-wrap pl0 mv0 list">'+"\n"
reactions.each do |reaction, count|
f << ' <li class="pv2 ph3 br b--light-gray">'+"\n"
if reaction === "plusOne"
f << ' <g-emoji alias="+1" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f44d.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="+1" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "minusOne"
f << ' <g-emoji alias="-1" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f44e.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="-1" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "laugh"
f << ' <g-emoji alias="smile" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f604.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="smile" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f604.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "hooray"
f << ' <g-emoji alias="tada" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f389.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="tada" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f389.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "confused"
f << ' <g-emoji alias="thinking_face" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f615.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="thinking_face" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f615.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "heart"
f << ' <g-emoji alias="heart" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/2764.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="heart" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/2764.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "eyes"
f << ' <g-emoji alias="eyes" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f440.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="eyes" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f440.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
if reaction === "rocket"
f << ' <g-emoji alias="rocket" fallback-src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f680.png" class="v-mid">'+"\n"
f << ' <img class="v-mid" alt="rocket" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png">'+"\n"
f << ' </g-emoji>' + count.to_s + "\n"
end
f << ' </li>'+"\n"
end
f << ' </ul>'+"\n"
f << ' </div>'+"\n"
f << ' </div>'+"\n"
f << '</div>'+"\n"
end
f << '<div class="pa3 tc ba b--light-gray black-70 mb3">'+"\n"
f << ' <a href="https://github.com/' + repo + '/issues/' + issue_id.to_s + '" class="b mid-gray">Join the discussion on GitHub</a>'+"\n"
f << '</div>'
else
f << '<div class="pa3 tc ba b--light-gray black-70 mb3">'+"\n"
f << ' There are currently no comments. <a href="https://github.com/' + repo + '/issues/' + issue_id.to_s + '" class="b mid-gray">Leave a comment on GitHub</a>'+"\n"
f << '</div>'
end
end
end
if issue
File.delete('source/_includes/comments/' + issue + '.html') if File.exist?('source/_includes/comments/' + issue + '.html')
create_comments(client, repo, issue)
else
# clear the old crags markdown files
Dir.foreach('source/_includes/comments') do |f|
fn = File.join('source/_includes/comments', f)
File.delete(fn) if f != '.' && f != '..'
end
# Loop through blog posts and grab front matter
Dir.glob("source/_posts/*.md") do |blog_post|
front_matter = YAML.load_file(blog_post)
# Get page.comments listed in front matter if published is true
if front_matter["published"] === true && front_matter["comment_issue_id"]
create_comments(client, repo, front_matter["comment_issue_id"])
end
end
end
Include HTML Snippet in Post Layout
In the post layout, check if the comment_issue_id
field exists before
including the auto-generated comment snippet related to that blog post. In this
example the snippet would be located at _includes/comments/3.html
.
{% if page.comment_issue_id %}
{% include {{ page.comment_issue_id | prepend: "comments/" | append: ".html" }} %}
{% endif %}
Style the Rendered Markdown
Then add a stylesheet for the rendered markdown that makes up the body of each comment.
.comment
.body
a
color: #FF725C
text-decoration: none
a:hover
color: #FF725C
text-decoration: underline
blockquote
border-left: 3px solid #ccc
margin-left: 15px
p
padding: 5px 0 5px 10px
& > p > code
background-color: #efefef
padding: 2px
font-size: .9em
pre > code
padding: 10px
display: block
white-space: pre
font-size: .9em
ul.task-list
list-style: none
padding-left: 20px
input.task-list-item-checkbox
margin-right: 5px
Create the GitHub Action
Use a GitHub Action to trigger a build any time an issue comment is created, edited, or deleted. Store the deploy key as an encrypted secret to prevent it from being stored in plain text.
name: Trigger Netlify Build
on: issue_comment
jobs:
build:
name: Request Netlify Webhook
runs-on: ubuntu-latest
steps:
- name: Curl request
env:
NETLIFY_DEPLOY_KEY: ${{ secrets.NETLIFY_DEPLOY_KEY }}
ISSUE_ID: ${{ github.event.issue.number }}
run: >
curl -X POST -d '{"scripts": "comments", "arguments": "'$ISSUE_ID'"}' https://api.netlify.com/build_hooks/$NETLIFY_DEPLOY_KEY
Create a Build Script
Finally, create a build script so that logic can be wrapped around the environmental variables that the builder passes.
#!/bin/bash -x
function urldecode() { : "${*//+/ }"; echo -e "${_//%/\\x}"; }
if [ -n "${INCOMING_HOOK_BODY}"]; then
PAYLOAD=$(urldecode "${INCOMING_HOOK_BODY}")
SCRIPTS_STRING=$(echo "${PAYLOAD}" | jq -r .scripts)
ARGUMENTS_STRING=$(echo "${PAYLOAD}" | jq -r .arguments)
case "$SCRIPTS_STRING" in
comments)
bundle exec ruby comments.rb $ARGUMENTS_STRING
;;
esac
fi
bundle exec jekyll build --source source
Feel free to leave a comment below to test out the system!
Here are some examples of supported GitHub Flavored Markdown with strong words, italic words, and
inline words
.