Iā€™ve been feeling blind when writing on my own website after things like Medium because I havenā€™t added a comments functionality. They are quite useful when blogging, you can have feedback, better or worse, points on which to improve, and just see that your blog is alive.

However, working with comments is certainly not an easy task - you must design and code a complete full-blown feature, including reactions, replies, and most importantly moderation. I couldā€™ve added a simple textarea with a button and some challenge to comment, but it is easily crackable in the age of AI.

Iā€™ve already seen a well-designed comment functionality in Tk Todoā€™s blog, and I remembered that it was something GitHub-powered. I did a fast google search and found out about utterances, which is a very simple to implement <script> tag that works based on your public GitHub repo.

It works by utilizing issues (which is creates automatically, if needed) on which the users can chat, reply to each other, react with emoji, add code block and report someone malicious if theyā€™d like. It also gates commenting behind GitHub OAuth, which should eliminate spam completely. It even features multiple themes, two of which Iā€™ve employed on my blog.

To set it up, we can pass through a simple form on their website, or just configure everything ourselves. Thereā€™s no huge documentation, so the builder was helpful for me to get started easily. Iā€™ve chosen issue-term (determinator of issue to be linked with a script block to comment in) to be title of my blogā€™s page (which I will probably change later as it is not really a constant variable), although thereā€™s a vast majority of options, including even og:meta parsing.

You can also create issues manually and link them by either issue number or a word in the title. It is sad that itā€™s not possible to link issues by tags, but thatā€™s quite a rough functionality to implement, because you can add multiple of the same tags on many issues. Hereā€™s my final script from the builder:

1
<script src="https://utteranc.es/client.js"
2
repo="[ENTER REPO HERE]"
3
issue-term="title"
4
label="blog"
5
theme="github-light"
6
crossorigin="anonymous"
7
async>
8
</script>

As my blog is a ā€œtemplateā€ for others to fork too (although not a big one!), Iā€™ve added a global UTTERANCES_CONFIG, which you can omit and use the object straight up later. Here, Iā€™ve chosen a label blog to mark all auto-created issues with it, and, as mentioned previously, issue-term is title. repo is in owner/repoName format, which Iā€™ve also included directly without the use of environment variables, but you can do whatever.

1
export const UTTERANCES_CONFIG = {
2
repo: "Serpentarius13/personal-blog",
3
label: "blog",
4
["issue-term"]: "title",
5
};

The functionality I needed was pretty dynamic, so I have decided to not add a static script element to HTML somewhere in my Astro blog page. Instead Iā€™ve created an UtterancesScript.astro file, which is a simple Astro component, to incapsulate the whole logic in there.

It first appends HTML markup stored in loader variable to render a little loader before comments appear, and then prepares a script element to be attached to utterances-container. Iā€™m attaching listener with addEventListener (which I hadnā€™t even know you could use like that!) to check for an event that utterances sends when it loads and I set the isLoaded flag to true. A MutationObserver later syncs the theme based on the theme set on websiteā€™s documentElement now, next theme is retrieved with getNextTheme function, and swapTheme sends a message to utterancesā€™s client.js script.

Hereā€™s a full version this component.

1
---
2
import { UTTERANCES_CONFIG } from "config";
3
---
4
5
<script is:inline define:vars={{ UTTERANCES_CONFIG }}>
6
// mark script as is:inline to not be processed by Astro
7
// pass UTTERANCES_CONFIG with define:vars
8
// you can specify options right inside this script if you
9
// don't want to move them out
10
11
// my darkThemes map object to check whether current theme's dark
12
const darkThemes = {
13
night: "night",
14
luxury: "luxury",
15
};
16
17
// loader markup
18
const loader = `
19
<div class="flex p-2 rounded-xl border border-primary items-center gap-2 mx-auto w-fit mt-4" id="utterances-spinner">
20
<span class="loading loading-infinity loading-xl">
21
</span>
22
<span class="text-xl">
23
Loading comments...
24
</span>
25
</div>`;
26
27
// utterances url for reuse
28
const UTTERANCES_URL = "https://utteranc.es";
29
30
let isLoaded = false;
31
// gets next comments theme based on current website theme
32
const getNextTheme = () => {
33
const theme = document.documentElement.dataset.theme || "";
34
return theme in darkThemes ? "gruvbox-dark" : "boxy-light";
35
};
36
37
// loader function to reuse twice
38
const load = () => {
39
// I've added a div with id of utterances-container right
40
// below my post's content. You can put it anywhere
41
const container = document.getElementById("utterances-container");
42
const loaderWrapper = document.getElementById("utterances-loader");
43
// check everything is loaded
44
if (!container) {
45
console.log("no utterances container found");
46
return;
47
}
48
if (!loaderWrapper) {
49
console.log("no utterances loaderWrapper found");
50
return;
51
}
52
53
// toggle loader
54
loaderWrapper.innerHTML = loader;
55
56
// extract theme
57
const theme = document.documentElement.dataset.theme || "";
58
59
// create script and map over attributes.
60
// it is important to use "setAttribute" instead
61
// of assigning because assigning won't work
62
// on html elements
63
const script = document.createElement("script");
64
65
const attributes = {
66
theme: getNextTheme(),
67
...UTTERANCES_CONFIG,
68
};
69
70
for (const [key, value] of Object.entries(attributes)) {
71
script.setAttribute(key, value);
72
}
73
74
// passing constant parameters
75
script.src = `${UTTERANCES_URL}/client.js`;
76
script.crossOrigin = "anonymous";
77
script.async = true;
78
79
script.onerror = (e) => console.log("error loading utterances:", e);
80
81
// check for load
82
addEventListener("message", (event) => {
83
if (event.origin !== UTTERANCES_URL) return;
84
isLoaded = true;
85
loaderWrapper.innerHTML = "";
86
});
87
88
// append a script to container to be ran
89
container.append(script);
90
};
91
92
// we retrieve iframe's contentWindow
93
// to postMessage there a "set-theme" event
94
const swapTheme = () => {
95
const message = {
96
type: "set-theme",
97
theme: getNextTheme(),
98
};
99
const utterances = document
100
.getElementById("utterances-container")
101
?.querySelector("iframe")?.contentWindow;
102
utterances?.postMessage(message, UTTERANCES_URL);
103
};
104
105
const observer = new MutationObserver((records) => {
106
if (!isLoaded) return;
107
108
for (const record of records) {
109
if (record.attributeName !== "data-theme") return;
110
111
swapTheme();
112
}
113
});
114
115
observer.observe(document.documentElement, {
116
attributes: true,
117
attributeFilter: ["data-theme"],
118
});
119
120
// load once on running the script
121
load();
122
</script>

Hereā€™s a simplified version for you to copy and paste:

1
---
2
3
---
4
5
<script is:inline>
6
const darkThemes = {
7
night: "night",
8
luxury: "luxury",
9
};
10
const UTTERANCES_URL = "https://utteranc.es";
11
12
let isLoaded = false;
13
const getNextTheme = () => {
14
const theme = document.documentElement.dataset.theme || "";
15
return theme in darkThemes ? "gruvbox-dark" : "boxy-light";
16
};
17
18
const load = () => {
19
const container = document.getElementById("utterances-container");
20
const loaderWrapper = document.getElementById("utterances-loader");
21
if (!container) {
22
console.log("no utterances container found");
23
return;
24
}
25
if (!loaderWrapper) {
26
console.log("no utterances loaderWrapper found");
27
return;
28
}
29
30
loaderWrapper.innerHTML = loader;
31
32
const script = document.createElement("script");
33
34
const attributes = {
35
theme: getNextTheme(),
36
...UTTERANCES_CONFIG,
37
};
38
39
for (const [key, value] of Object.entries(attributes)) {
40
script.setAttribute(key, value);
41
}
42
43
script.src = `${UTTERANCES_URL}/client.js`;
44
script.crossOrigin = "anonymous";
45
script.async = true;
46
47
script.onerror = (e) => console.log("error loading utterances:", e);
48
49
addEventListener("message", (event) => {
50
if (event.origin !== UTTERANCES_URL) return;
51
isLoaded = true;
52
loaderWrapper.innerHTML = "";
53
});
54
55
container.append(script);
56
};
57
58
const swapTheme = () => {
59
const message = {
60
type: "set-theme",
61
theme: getNextTheme(),
62
};
63
const utterances = document
64
.getElementById("utterances-container")
65
?.querySelector("iframe")?.contentWindow;
66
utterances?.postMessage(message, UTTERANCES_URL);
67
};
68
69
const observer = new MutationObserver((records) => {
70
if (!isLoaded) return;
71
72
for (const record of records) {
73
if (record.attributeName !== "data-theme") return;
74
75
swapTheme();
76
}
77
});
78
79
observer.observe(document.documentElement, {
80
attributes: true,
81
attributeFilter: ["data-theme"],
82
});
83
84
load();
85
</script>

Markup below the post looks like this:

1
<div>
2
<div id="utterances-loader"></div>
3
<div id="utterances-container"></div>
4
</div>

As utterances creates issues with a template that I didnā€™t like (a title with a url to follow in contents - yes I wanted a link with a name), Iā€™ve also decided to create issues for my four blog posts myself in this manner:

A GitHub issue with post name as title and 'Post' hyperlink leading to the post's page

If you have a lot of articles, you can use GitHub API to do this automatically on a large dataset retrieved from your database or Content Collections. You can run it every time your application deploys to ensure that every article has its issue to comment.

Thatā€™s all! Thanks for reading!