Mastodon Comments in Bookstack
There are several people online who have written JS scripts to turn a Mastodon thread into a comment thread on a website. The ones I came across did not directly work with Bookstack however. This is a quick guide for doing the same with Bookstack, although there are surely better ways, this was very quick to setup.
What to see what it looks like first? Just scroll down, it's on this page!
Based on this Repo: https://github.com/dpecos/mastodon-comments
In Bookstack go to Settings > Customization
Add the following into the Custom HTML Head Content section:
Custom HTML Head Content
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.1/purify.min.js" integrity="sha512-uHOKtSfJWScGmyyFr2O2+efpDx2nhwHU2v7MVeptzZoiC7bdF6Ny/CmZhN2AwIK1oCFiVQQ5DA/L9FSzyPNu6Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<!-- Convert Mastodon Toot link into comment form: https://github.com/dpecos/mastodon-comments -->
<!-- This is needed b/c Bookstack doesn't allow custom tags like "mastodon-comments" directly in the page -->
<!-- Recommended link format: <a class="mastodon-link" href="https://hostux.social/@JaggedJax/TOOT_ID" target="_blank" rel="noopener">Leave a Comment</a> -->
<script>
const styles = `
:root {
--font-color: #5d686f;
--font-size: 1.0rem;
--block-border-width: 1px;
--block-border-radius: 3px;
--block-border-color: #ededf0;
--block-background-color: #f7f8f8;
--comment-indent: 40px;
}
mastodon-comments {
font-size: var(--font-size);
}
p {
margin: 0 0 1rem 0;
}
#mastodon-stats {
text-align: center !important;
font-size: calc(var(--font-size) * 1.5);
}
#mastodon-title {
font-size: calc(var(--font-size) * 1.5);
font-weight: bold !important;
}
#mastodon-comments-list {
margin: 0 auto;
padding: 0;
}
#mastodon-comments-list ul {
padding-left: var(--comment-indent);
}
#mastodon-comments-list li {
list-style: none;
}
.mastodon-comment {
background-color: var(--block-background-color);
border-radius: var(--block-border-radius);
border: var(--block-border-width) var(--block-border-color) solid;
padding: 15px;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
color: var(--font-color);
}
.mastodon-comment p {
margin-bottom: 0px;
}
.mastodon-comment .author {
padding-top:0;
display:flex;
}
.mastodon-comment .author a {
text-decoration: none;
}
.mastodon-comment .author .avatar img {
margin-right:1rem;
min-width:60px;
border-radius: 5px;
}
.mastodon-comment .details {
margin-left: 35px;
}
.mastodon-comment .author .details {
display: flex;
flex-direction: column;
line-height: 1.2em;
}
.mastodon-comment .author .details .name {
font-weight: bold;
}
.mastodon-comment .author .details .user {
color: #5d686f;
font-size: medium;
}
.mastodon-comment .author .date {
margin-left: auto;
font-size: small;
}
.mastodon-comment .content {
margin: 15px 0;
line-height: 1.5em;
}
.mastodon-comment .author .details a,
.mastodon-comment .content p {
margin-bottom: 10px;
}
.mastodon-comment .attachments {
margin: 0px 10px;
}
.mastodon-comment .attachments > * {
margin: 0px 10px;
}
.mastodon-comment .attachments img {
max-width: 100%;
}
.mastodon-comment .status > div, #mastodon-stats > div {
display: inline-block;
margin-right: 15px;
}
.mastodon-comment .status a, #mastodon-stats a {
color: #5d686f;
text-decoration: none;
}
.mastodon-comment .status .replies.active a, #mastodon-stats .replies.active a {
color: #003eaa;
}
.mastodon-comment .status .reblogs.active a, #mastodon-stats .reblogs.active a {
color: #8c8dff;
}
.mastodon-comment .status .favourites.active a, #mastodon-stats .favourites.active a {
color: #ca8f04;
}
`;
class MastodonComments extends HTMLElement {
constructor() {
super();
this.host = this.getAttribute("host");
this.user = this.getAttribute("user");
this.tootId = this.getAttribute("tootId");
this.commentsLoaded = false;
const styleElem = document.createElement("style");
styleElem.innerHTML = styles;
document.head.appendChild(styleElem);
}
connectedCallback() {
this.innerHTML = `
<div id="mastodon-stats"></div>
<div id="mastodon-title">Comments</div>
<noscript>
<div id="error">
Please enable JavaScript to view the comments powered by the Fediverse.
</div>
</noscript>
<p>You can use your Fediverse (i.e. Mastodon, among many others) account to reply to this <a class="link"
href="https://${this.host}/@${this.user}/${this.tootId}" rel="ugc">post</a>.
</p>
<ul id="mastodon-comments-list"></ul>
`;
const comments = document.getElementById("mastodon-comments-list");
const rootStyle = this.getAttribute("style");
if (rootStyle) {
comments.setAttribute("style", rootStyle);
}
this.respondToVisibility(comments, this.loadComments.bind(this));
}
escapeHtml(unsafe) {
return (unsafe || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
toot_active(toot, what) {
var count = toot[what + "_count"];
return count > 0 ? "active" : "";
}
toot_count(toot, what) {
var count = toot[what + "_count"];
return count > 0 ? count : "";
}
toot_stats(toot) {
return `
<div class="replies ${this.toot_active(toot, "replies")}">
<a href="${
toot.url
}" rel="ugc nofollow"><i class="fa fa-reply fa-fw"></i>${this.toot_count(
toot,
"replies",
)}</a>
</div>
<div class="reblogs ${this.toot_active(toot, "reblogs")}">
<a href="${
toot.url
}/reblogs" rel="nofollow"><i class="fa fa-retweet fa-fw"></i>${this.toot_count(
toot,
"reblogs",
)}</a>
</div>
<div class="favourites ${this.toot_active(toot, "favourites")}">
<a href="${
toot.url
}/favourites" rel="nofollow"><i class="fa fa-star fa-fw"></i>${this.toot_count(
toot,
"favourites",
)}</a>
</div>
`;
}
user_account(account) {
var result = `@${account.acct}`;
if (account.acct.indexOf("@") === -1) {
var domain = new URL(account.url);
result += `@${domain.hostname}`;
}
return result;
}
render_toots(toots, in_reply_to) {
var tootsToRender = toots
.filter((toot) => toot.in_reply_to_id === in_reply_to)
.sort((a, b) => a.created_at.localeCompare(b.created_at));
tootsToRender.forEach((toot) => this.render_toot(toots, toot));
}
render_toot(toots, toot) {
toot.account.display_name = this.escapeHtml(toot.account.display_name);
toot.account.emojis.forEach((emoji) => {
toot.account.display_name = toot.account.display_name.replace(
`:${emoji.shortcode}:`,
`<img src="${this.escapeHtml(emoji.static_url)}" alt="Emoji ${
emoji.shortcode
}" height="20" width="20" />`,
);
});
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
formatMatcher: 'basic'
}).replace(',', '').replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2')
}
const mastodonComment = `
<article class="mastodon-comment">
<div class="author">
<div class="avatar">
<img src="${this.escapeHtml(
toot.account.avatar_static,
)}" height=60 width=60 alt="">
</div>
<div class="details">
<a class="name" href="${toot.account.url}" rel="nofollow">${
toot.account.display_name
}</a>
<a class="user" href="${
toot.account.url
}" rel="nofollow">${this.user_account(toot.account)}</a>
</div>
<a class="date" href="${
toot.url
}" rel="nofollow">
<time datetime="${toot.created_at}">
${formatDate(toot.created_at)}${toot.edited_at ? "*" : ""}
</time>
</a>
</div>
<div class="content">${toot.content}</div>
<div class="attachments">
${toot.media_attachments
.map((attachment) => {
if (attachment.type === "image") {
return `<a href="${attachment.url}" rel="ugc nofollow"><img src="${
attachment.preview_url
}" alt="${this.escapeHtml(attachment.description)}" loading="lazy" /></a>`;
} else if (attachment.type === "video") {
return `<video controls preload="none"><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
} else if (attachment.type === "gifv") {
return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
} else if (attachment.type === "audio") {
return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
} else {
return `<a href="${attachment.url}" rel="ugc nofollow">${attachment.type}</a>`;
}
})
.join("")}
</div>
<div class="status">
${this.toot_stats(toot)}
</div>
</article>
`;
var li = document.createElement("li");
li.setAttribute("id", toot.id)
li.innerHTML =
typeof DOMPurify !== "undefined"
? DOMPurify.sanitize(mastodonComment.trim())
: mastodonComment.trim();
if (toot.in_reply_to_id === this.tootId) {
document
.getElementById("mastodon-comments-list")
.appendChild(li);
} else {
const parentToot = toots.find(t => t.id === toot.in_reply_to_id);
if (parentToot) {
const ul = document.createElement('ul');
document
.getElementById(toot.in_reply_to_id)
.appendChild(ul)
.appendChild(li);
}
}
this.render_toots(toots, toot.id);
}
loadComments() {
if (this.commentsLoaded) return;
document.getElementById("mastodon-comments-list").innerHTML =
"Loading comments from the Fediverse...";
let _this = this;
fetch("https://" + this.host + "/api/v1/statuses/" + this.tootId)
.then((response) => response.json())
.then((toot) => {
document.getElementById("mastodon-stats").innerHTML =
this.toot_stats(toot);
});
fetch(
"https://" + this.host + "/api/v1/statuses/" + this.tootId + "/context",
)
.then((response) => response.json())
.then((data) => {
if (
data["descendants"] &&
Array.isArray(data["descendants"]) &&
data["descendants"].length > 0
) {
document.getElementById("mastodon-comments-list").innerHTML = "";
_this.render_toots(data["descendants"], _this.tootId, 0);
} else {
document.getElementById("mastodon-comments-list").innerHTML =
"<p>No comments found</p>";
}
_this.commentsLoaded = true;
});
}
respondToVisibility(element, callback) {
var options = {
root: null,
};
var observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
callback();
}
});
}, options);
observer.observe(element);
}
}
function convertMastodonLink() {
return new Promise((resolve, reject) => {
// Find the anchor tag with class "mastodon-link"
const mastodonLink = document.querySelector('a.mastodon-link');
if (!mastodonLink) {
reject('No anchor tag with class "mastodon-link" found');
}
// Get the href attribute
const href = mastodonLink.href;
if (!href) {
reject('Mastodon link has no href attribute');
}
try {
// Parse the URL to extract host and tootId
const url = new URL(href);
const host = url.hostname;
// Extract tootId from pathname (assuming format like /@user/123456789)
const pathParts = url.pathname.split('/');
const tootId = pathParts[pathParts.length - 1];
// Extract username (assuming format like /@username/tootId)
let user = '';
const userIndex = pathParts.findIndex(part => part.startsWith('@'));
if (userIndex !== -1) {
user = pathParts[userIndex].substring(1); // Remove the @ symbol
}
// Validate that we have the required data
if (!host || !user || !tootId || isNaN(tootId)) {
reject('Could not parse host, user, or tootId from URL:', href);
}
// Create the new mastodon-comments element
const mastodonComments = document.createElement('mastodon-comments');
mastodonComments.setAttribute('host', host);
mastodonComments.setAttribute('user', user);
mastodonComments.setAttribute('tootId', tootId);
mastodonComments.setAttribute('style', 'width: 840px');
// Replace the original anchor tag with the new element
mastodonLink.parentNode.replaceChild(mastodonComments, mastodonLink);
console.log(`Successfully converted mastodon link to mastodon-comments element`);
console.log(`Host: ${host}, User: ${user}, TootId: ${tootId}`);
resolve();
} catch (error) {
console.error('Error parsing Mastodon URL:', error);
reject(error);
}
});
}
function loadMastodonScript() {
return new Promise((resolve, reject) => {
customElements.define("mastodon-comments", MastodonComments);
});
}
// Execute conversion first, then load MastodonComments
document.addEventListener('DOMContentLoaded', async () => {
try {
await convertMastodonLink();
console.log('Mastodon conversion complete, loading MastodonComments');
await loadMastodonScript();
console.log('MastodonComments loaded successfully');
} catch (error) {
console.warn('Unable to load MastodonComments:', error);
}
});
</script>
Now on the page you want to add comments, add the following <a> tag where you want the comments to load. Fill in the link to your post on your home server:<a class="mastodon-link" href="https://hostux.social/@JaggedJax/TOOT_ID" target="_blank" rel="noopener">Leave a Comment</a>
Note: You need to click the <> icon in the Bookstack WYSIWYG editor to paste in this link there. The WYSIWYG does not let you set a custom class that we're using to convert this link. Users without Javascript will just see the link to you post.
How It Works:
The mastodon-comments script is looking for a custom HTML tag which Bookstack doesn't allow. This script instead looks for an <a> tag with the class mastodon-link, and converts that into the necessary mastodon-comments tag. This gives the advantage of working well without JS enabled as well.
If no valid <a> tag is found then the mastodon-comments script is not triggered.
Improvements:
- The JS here could be cleaned up a lot. Much of the CSS is unused, and the use of a Promise is overkill here. That's leftover from my initial attempts and is not necessary.
- Font Awesome is being used to show icons. It would be nice to remove that
- The mastodon-comments script this is taken from uses DOMPurify to help prevent XSS. That's a great thing, but we could just embed the code locally to avoid the callout.
- If you make your own improvements please comment and let me know so I can make them too!
No comments to display
No comments to display