Published: 12/8/2021. Updated: 12/12/2021
I recently updated my blog to use GitHub issue tracking for blog post comments. The previously used Facebook Comments plugin didn't work so smoothly anymore, so it was time to evaluate alternatives.
I have been struggling for a while with problems with that comments plugin (X-Frame-Options: DENY). I tried all kinds of things but never got it working properly. Another motivation for a change was that I wanted to remove external Facebook JavaScripts from my site due to tracking and privacy issues. Also, to boost the loading performance by using less JavaScript.
I tried to find a simple and free service for the comments, but I had no luck with that path. Commento.io was one of the possible options, but I saved that for later, as they don't have a free plan. After reading several blog posts, I found that using GitHub issues could be a pretty practical approach for commenting. Also, it's possible to use GitHub issues for the actual blog post content. But as I have a working headless CMS for content management already, I only use it for comments.
At the same time, I changed my blog platform from Svelte and Sapper to the Next.js framework, as I wanted to experiment with React and Next.js. I already have experience with Vue and Nuxt and Angular(.js), but not much with React. This blog post shows an example of how I did the GitHub issues integration with Next.js.
The downside is that the commenting UX is not optimal, as it happens via GitHub. But that should be sufficient for a developer blog like mine. There is also the GitHub API for creating the comments, so it's possible to polish the UX later. In general, it feels like a good fit, as this blog is all about web development, and GitHub is also all about that.
Unfortunately, all the previous comments gathered by the Facebook Comments are no longer available. Luckily there weren't so many of those. So thanks for all the commenters, and sorry that your comments are now gone.
While I did the comment platform research, I found the following projects that provide similar features:
Anyway, as this seemed to be an interesting coding exercise and good fuel for the blog post, I decided to implement commenting by myself. Also, this was a good opportunity to explore the GitHub APIs.
This blog post will cut a few corners as it's not a complete tutorial. But hopefully, it gives some idea of how to implement the commenting feature. Examples are for the server-side rendering, not for the static site generation. Also, the principles should be quite easy to use with any other framework.
If you have previous experience with Next.js or a project already set up, then you are good to go. Otherwise follow their tutorial on how to get started with Next.js. The Tailwind CSS framework is in use for styling the components.
Following GitHub API endpoints will be used:
From the issue endpoint GET /repos/{owner}/{repo}/issues/{issue_number}
we want to have the information if the issue has been locked or closed. The actual comments are fetched from the GET /repos/{owner}/{repo}/issues/{issue_number}/comments
.
GitHub API uses rate-limiting, so we have to deal with that somehow. For unauthenticated requests, the limit is only 60 requests per hour (from originating IP address, that is the server in this case), which are going to be used fairly fast.
By providing the authenticated user, it's possible to make up to 5000 requests per hour, which starts to be a reasonable amount. In this case, the requests are associated with the authenticated user.
In GitHub's settings, you can create a Personal Access Token. The scope can be left unselected when this token only allows access to the public data (as our comments must be public). The easiest way to use API with the token is the basic authentication, which is simple to implement with Axios.
In addition to authentication, I will cache the requests using the node-cache library. It's up to you how long you want to cache the data, but I am evaluating something like 10 seconds, which shouldn't harm the UX that much. But which will prevent the rate-limiting from kicking in.
You can also check rate limits from the GitHub API response and react to those.
curl --head -u <username>:<token> https://api.github.com/repos/<account>/<repository>/issues/1
It will display following headers:
HTTP/2 200
server: GitHub.com
...
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4997
x-ratelimit-reset: 1638814554
x-ratelimit-used: 3
At first, you need to create a public repository for the comments. For example, my blog comments are available at: github.com/teroauralinna/auralinna.blog
I added the label blog post comments to the comment issues. Then it's easy to filter those if there is also something else in the future. The title is the blog post name, and each one has a link to the blog post.
I created the following issue template for helping the issue creation. When adding a new issue, I just need to replace the blog post title and blog post link. The label and assignee will be set by the template.
The following npm dependencies must be installed in addition to Next.js:
npm i axios dompurify jsdom markdown-it shiki node-cache --save
npm i @types/dompurify @types/jsdom @types/markdown-it tailwindss autoprefixer postcss --save-dev
Let's continue by adding the Next.js API route for fetching the actual comments from GitHub.
The endpoint will:
GitHub also has its own Markdown API. For now, I just used the markown-it library for parsing the comments markdown. If there are any special needs for the markdown parsing, I'll probably switch to GitHub's Markdown API, but for now, local parsing seems to be fine.
./interfaces/github-api.ts: interfaces for the GitHub API responses
export enum GitHubIssueState {
open = 'open',
closed = 'closed'
}
export interface GitHubIssue {
locked: boolean
state: GitHubIssueState
}
export interface GitHubUser {
id: number
login: string
html_url: string
}
export interface GitHubIssueComment {
id: number
body: string,
created_at: string
updated_at: string
html_url: string
user: GitHubUser
}
export interface GitHubAuthOptions {
username: string,
token: string
}
export interface GitHubApiOptions {
auth: GitHubAuthOptions
}
./interfaces/blog.ts: interfaces for the internal data models
export interface CommentUser {
id: number
login: string
url: string
}
export interface Comment {
id: number
body: string
createdAt: Date
updatedAt: Date
commentUrl: string
user: CommentUser
}
export interface CommentThread {
active: boolean
comments: Array<Comment>
}
./utils/sanitizer.ts: a utility function for sanitizing the HTML output
The dompurify library is used here to sanitize the HTML and prevent XSS attacks.
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
const window = new JSDOM().window
export const sanitizeHtml = (unsafeContent: string): string => {
// @ts-ignore
const DOMPurify = createDOMPurify(window)
return DOMPurify.sanitize(unsafeContent)
}
./env.development and ./env.production
Environment variables will hold the GitHub API endpoint URLs.
COMMENTS_API_URL=https://api.github.com/repos/<account>/<repository>/issues
COMMENTS_PUBLIC_URL=https://github.com/<account>/<repository>/issues
In the local development env, use the env.local
file to store the GitHub authentication credentials. Don't commit this file into version control. This post doesn't contain exact details on how to manage environment variables in production. That depends on where and how you are hosting your Next.js project.
./env.local
GITHUB_USERNAME=<username>
GITHUB_PERSONAL_ACCESS_TOKEN=<access token>
./api/github-http.ts: GitHub API functions
API functions for fetching the data. You can also use this library octokit/core.js (extendable client for GitHub's REST & GraphQL APIs).
import axios, {
AxiosBasicCredentials,
AxiosRequestConfig
} from 'axios'
import {
GitHubIssue,
GitHubIssueComment,
GitHubApiOptions,
GitHubAuthOptions
} from '@/interfaces/github-api'
const getRequestConfig = (auth: GitHubAuthOptions): AxiosRequestConfig => {
const basicCredentials: AxiosBasicCredentials = {
username: auth.username,
password: auth.token
}
return {
auth: basicCredentials
}
}
export const getIssue = async (id: number, options: GitHubApiOptions): Promise<GitHubIssue> => {
const response = await axios.get(`${process.env.COMMENTS_API_URL}/${id}`, getRequestConfig(options.auth))
return response.data
}
export const getComments = async (id: number, options: GitHubApiOptions): Promise<Array<GitHubIssueComment>> => {
const response = await axios.get(`${process.env.COMMENTS_API_URL}/${id}/comments`, getRequestConfig(options.auth))
return response.data
}
./pages/api/v1/comments/[id].ts: API route endpoint
Next we'll add the actual endpoint for getting the comments.
import type { NextApiRequest, NextApiResponse } from 'next'
import NodeCache from 'node-cache'
import markdown from 'markdown-it'
import {
getHighlighter,
Highlighter
} from 'shiki'
import {
Comment,
CommentThread
} from '@/interfaces/blog'
import {
GitHubIssue,
GitHubIssueComment,
GitHubIssueState,
GitHubApiOptions,
GitHubAuthOptions
} from '@/interfaces/github-api'
import { sanitizeHtml } from '@/utils/sanitizer'
import {
getComments,
getIssue
} from 'api/http-github'
const replaceUserMentions = (html: string): string =>
html
.replace(/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}/g, '<a href="https://github.com/$&">$&</a>')
.replace(/https:\/\/github.com\/@/g, 'https://github.com/')
const commentsCache = new NodeCache({ stdTTL: 10 })
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<CommentThread>
) {
try {
const id: number = parseInt(req.query.id as string)
const getCacheKey = (id: number): string => `comment-thread-${id}`
const cachedCommentThread: CommentThread | undefined = commentsCache.get(getCacheKey(id))
if (cachedCommentThread) {
console.info(`Comment thread found from the cache: ${getCacheKey(id)}. TTL: ${commentsCache.getTtl(getCacheKey(id))}. Stats: ${JSON.stringify(commentsCache.getStats())}`)
res.send(cachedCommentThread)
return
}
const authOptions: GitHubAuthOptions = {
username: process.env.GITHUB_USERNAME!,
token: process.env.GITHUB_PERSONAL_ACCESS_TOKEN!
}
const options: GitHubApiOptions = { auth: authOptions }
const gitHubIssue: GitHubIssue = await getIssue(id, options)
const gitHubComments: Array<GitHubIssueComment> = await getComments(id, options)
const { codeToHtml }: Highlighter = await getHighlighter({
theme: 'github-light'
})
const md = markdown({
html: true,
highlight: (code: string, lang: string) => codeToHtml(code, { lang })
})
const comments: Array<Comment> = gitHubComments.map((el: GitHubIssueComment) => {
const comment: Comment = {
id: el.id,
body: sanitizeHtml(replaceUserMentions(md.render(el.body))),
createdAt: new Date(el.created_at),
updatedAt: new Date(el.updated_at),
commentUrl: el.html_url,
user: {
id: el.user.id,
login: el.user.login,
url: el.user.html_url
}
}
return comment
})
const isActive = !gitHubIssue.locked && gitHubIssue.state !== GitHubIssueState.closed
const commentThread: CommentThread = {
active: isActive,
comments
}
commentsCache.set(getCacheKey(id), commentThread)
console.info(`Comment thread set to the cache: ${getCacheKey(id)}`)
res.send(commentThread)
} catch (error) {
console.error(error)
res
.status(500)
.send({
active: false,
comments: []
})
}
}
Now, if everything is correct, you should be able to see the comments in the local dev env in the URL: http://localhost:3000/api/v1/comments/1
I'm using Shiki Syntax Highlighter with markdown-it to parse Markdown and highlight code blocks in comments. I have used Prism.js and Highlight.js before, but so far, Shiki does the best job of getting the syntax highlight colorized correctly.
It uses TextMate grammar to tokenize strings, and colors the tokens with VS Code themes. In short, Shiki generates HTML that looks exactly like your code in VS Code, and it works great in your static website generator. Quote from https://shiki.matsu.io
Shiki is easy to integrate into the markdown parsing. These lines will do the job:
const { codeToHtml }: Highlighter = await getHighlighter({
theme: 'material-palenight'
})
const md = markdown({
html: true,
highlight: (code: string, lang: string) => codeToHtml(code, { lang })
})
And then the actual parsing:
const comments: Array<Comment> = gitHubComments.map((el: GitHubIssueComment) => {
const comment: Comment = {
...
body: sanitizeHtml(replaceUserMentions(md.render(el.body))),
...
}
return comment
})
The next thing to do is to add components for displaying the comments.
In the comment list, it's possible to toggle the comment button visibility by the isCommentingOpen
prop. It's disabled if the GitHub issue state is set as closed, or the conversation is locked.
./components/CommentList/CommentList.tsx: the CommentList
component will render the comment list and comment button
import { Comment } from '@/components'
import { Comment as CommentInterface } from '@/interfaces/blog'
const CommentList = ({
comments,
commentThreadUrl,
isCommentingOpen
}: {
comments: Array<CommentInterface>,
commentThreadUrl: string,
isCommentingOpen: boolean
}) => {
return (
<div>
{comments.length > 0 ?
comments.map(comment =>
<Comment
key={comment.id}
comment={comment}
/>
) : <p className="text-center">Be the first commenter?</p>
}
{isCommentingOpen &&
<div className="text-center pt-2">
<a
className="px-6 py-2 bg-black text-white uppercase inline-block transition-transform duration-200 hover:scale-125 focus:scale-125 active:scale-100 focus:shadow hover:shadow"
href={commentThreadUrl}
target="_blank"
rel="noopener noreferrer"
>
Comment the Blog Post on GitHub
</a>
</div>
}
</div>
)
}
export default CommentList
./components/Comment/Comment.tsx: the Comment
component will render a single comment
The Comment.module.css
file includes styles for the comment's Markdown content. The Tailwind Typography plugin could work well here also.
import styles from './Comment.module.css'
import { Comment as CommentInterface } from '@/interfaces/blog'
const Comment = ({ comment }: { comment: CommentInterface }) => {
return (
<div className="bg-gray-100 mb-4 p-4">
<p className="text-base text-gray-600">
<span>Comment by </span>
<a
href={comment.user.url}
className="font-medium underline hover:text-electric-violet"
target="_blank"
rel="noopener noreferrer"
>
{comment.user.login}
</a>
<span> at </span>
<a
href={comment.commentUrl}
className="font-medium underline hover:text-electric-violet"
target="_blank"
rel="noopener noreferrer"
>
{new Date(comment.createdAt).toLocaleDateString()}
</a>.
{comment.createdAt !== comment.updatedAt &&
<span> Updated at {new Date(comment.updatedAt).toLocaleDateString()}.</span>
}
</p>
<div
className={styles.comment}
dangerouslySetInnerHTML={{ __html: comment.body }}
/>
</div>
)
}
export default Comment
./api/http.ts: the API function for getting the comments from the Next.js API route
import axios from 'axios'
import { CommentThread } from '@/interfaces/blog'
export const getComments = async (commentThreadId: number): Promise<CommentThread> => {
const response = await axios.get(`${process.env.LOCAL_API_BASE_PATH}/comments/${commentThreadId}`)
return response.data
}
Finally, let's add a page for displaying the comments. As I use Next.js server-side rendering, I'm fetching the data in getServerSideProps function.
The following is not a complete code example. Implementation depends on where the comment thread ID (the GitHub issue ID) is stored. The code example just set it statically to be the number 1.
./pages/post/[slug].tsx: the page where the comments are visible
export async function getServerSideProps() {
try {
const commentThreadId: number = 1
const commentThreadUrl: string = `${process.env.COMMENTS_PUBLIC_URL}/${commentThreadId}#new_comment_field`
let commentThread: CommentThread = {
active: false,
comments: []
}
if (commentThreadId) {
commentThread = await getComments(commentThreadId)
}
return {
props: {
isCommentingOpen: commentThread.active,
comments: commentThread.comments,
commentThreadUrl,
commentThreadId
}
}
} catch (error) {
return {
notFound: true
}
}
}
JSX/TSX
In the template part, you can render the comment list by the CommentList
component, which should now have the required data available.
{commentThreadId &&
<CommentList
comments={comments}
commentThreadUrl={commentThreadUrl}
isCommentingOpen={isCommentingOpen}
/>
}
When I create a new blog post, I will create a new issue to my GitHub repository and link the issue ID into a blog post. That will enable the commenting.
Here are a few ideas I left away from the initial phase.
The post updated at 11.12.2021:
I am an experienced web developer with an eye for solid UI/UX design. I have specialized in front-end development, responsive web design, design systems, modern web frameworks, and content management systems. I also have experience in mobile apps development and back-end coding with PHP, Node.js, and Java. So I have a full stackish background, but I'm enjoying most building robust and beautiful front-ends with performance, accessibility, and testability in mind.
© Tero Auralinna
Auralinna.fiSunset with Bubbles: Travel and Photography Blog
Here’s a test comment. I’m exploring this mechanism for my own blog. Are there #hashtags?