Compare commits

...

5 Commits

4 changed files with 291 additions and 64 deletions

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# Hugo Mastodon Comments
A Hugo theme component that displays Mastodon comments on your blog posts. When you share a blog post on Mastodon, replies to that toot are automatically fetched and displayed as comments on your site.
## How It Works
1. You publish a blog post and share it on Mastodon
2. The script searches your Mastodon timeline for toots containing links to your blog
3. When a matching toot is found, it fetches all replies (descendants)
4. Comments are displayed on your blog with author info, avatars, and engagement stats
5. Results are cached to minimize API calls
## Setup
### 1. Copy Files
Copy the `static/comments/` directory to your Hugo site's static folder or use this as a theme component.
### 2. Configure Backend
Create `static/comments/config.php` from the sample:
```php
<?php
$config = [
'mastodon-instance' => 'https://mastodon.social',
'user-id' => 123456, // Your Mastodon user ID
'search-url' => 'https?://yourdomain.com',
'cache_toots' => 300, // Cache toots list for 5 minutes
'cache_comments' => 300, // Cache comments per toot for 5 minutes
'debug' => false
];
```
To find your Mastodon user ID, check your profile API: `https://mastodon.social/api/v1/accounts/lookup?acct=username`
### 3. Configure Hugo
Add to your `config.toml`:
```toml
[params.mastodoncomments]
user = "@username@mastodon.social"
regex = "yourdomain.com"
contact = "https://mastodon.social/@username"
```
### 4. Add to Template
Include the partial in your blog post layout:
```html
{{ partial "comments.html" . }}
```
## Usage
### Display Comments
Comments are loaded automatically via JavaScript when visitors view a blog post. The script:
- Extracts the current page's permalink
- Queries the PHP backend to find matching Mastodon toots
- Displays replies as comments with full formatting
### Optional: Direct Toot URL (Recommended for Performance)
For better performance, you can specify the Mastodon toot URL directly in your post's front matter:
```yaml
---
title: "My Post Title"
mastodon_toot_url: "https://mastodon.social/@username/113456789012345678"
---
```
**Benefits:**
- Skips the entire toot search phase
- Reduces API calls significantly
- Faster page loads
- Works across different Mastodon instances
- Ensures the correct toot is always used
**When to use:**
- For any post where you know the Mastodon toot URL
- Especially recommended for popular or archived posts
- When you've shared the post on a different Mastodon instance
If no `mastodon_toot_url` is provided, the system automatically falls back to searching for toots containing the post URL.
### Force Cache Refresh
To manually clear and rebuild the cache:
```
https://yourdomain.com/comments/getcomments.php?force_refresh=1
```
### Cache Configuration
Adjust cache times based on your needs:
- **Lower values (60-300s)**: Faster updates, more API calls
- **Higher values (1800-3600s)**: Reduced load, slower updates
For most blogs, 300-1800 seconds is appropriate.
## Requirements
- PHP 7.0+ with `file_get_contents` enabled
- Write permissions for cache files in the comments directory
- Network access to your Mastodon instance API
## License
- PHP backend: AGPL-3.0-or-later
- JavaScript: AGPL-3.0-or-later
- Configuration samples: CC0-1.0
## Credits
Based on work by Björn Schießle.

View File

@@ -4,6 +4,7 @@
SPDX-FileCopyrightText: 2019 Björn Schießle SPDX-FileCopyrightText: 2019 Björn Schießle
--> -->
{{ if ne .Params.page true }} {{ if ne .Params.page true }}
<hr />
<div class="comments-container"> <div class="comments-container">
<h5>Comments</h5> <h5>Comments</h5>
<noscript> <noscript>
@@ -29,3 +30,4 @@
<script>var MastodonUser="{{ .Site.Params.mastodoncomments.user }}"</script> <script>var MastodonUser="{{ .Site.Params.mastodoncomments.user }}"</script>
<script>var BlogRegex="{{ .Site.Params.mastodoncomments.regex }}"</script> <script>var BlogRegex="{{ .Site.Params.mastodoncomments.regex }}"</script>
<script>var CommentsContact="{{ .Site.Params.mastodoncomments.contact }}"</script> <script>var CommentsContact="{{ .Site.Params.mastodoncomments.contact }}"</script>
<script>var MastodonTootUrl="{{ .Params.mastodon_toot_url }}"</script>

View File

@@ -4,6 +4,18 @@
SPDX-FileCopyrightText: 2019 Björn Schießle SPDX-FileCopyrightText: 2019 Björn Schießle
*/ */
// Debug logging helper
var debugEnabled = false;
function debugLog(message, data) {
if (debugEnabled) {
if (data !== undefined) {
console.log('[Mastodon Comments] ' + message, data);
} else {
console.log('[Mastodon Comments] ' + message);
}
}
}
$(document).ready(function() { $(document).ready(function() {
// check if we show a blog post or not. Regex is defined in site-wide config // check if we show a blog post or not. Regex is defined in site-wide config
@@ -14,20 +26,45 @@ $(document).ready(function() {
return; return;
} }
debugLog('Searching for comments for: ' + RelPermalink);
// Check if a direct Mastodon toot URL is provided
var ajaxData = { search: RelPermalink };
if (MastodonTootUrl && MastodonTootUrl !== "") {
// Pass the full URL to PHP so it can extract instance and ID
ajaxData.toot_url = MastodonTootUrl;
debugLog('Using predefined Mastodon toot URL: ' + MastodonTootUrl);
}
$.ajax({ $.ajax({
url: "/comments/getcomments.php", url: "/comments/getcomments.php",
type: "get", type: "get",
data: { data: ajaxData,
search : RelPermalink
},
success: function(data) { success: function(data) {
// Enable debug logging if backend has debug enabled
if (data.debug === true) {
debugEnabled = true;
debugLog('Debug mode enabled');
}
debugLog('Received data from backend', data);
var stats = data.stats; var stats = data.stats;
var root = data.stats.root; var root = data.stats.root;
debugLog('Stats - Favs: ' + stats.favs + ', Reblogs: ' + stats.reblogs + ', Replies: ' + stats.replies);
debugLog('Root toot ID: ' + root);
$("#like-count-container").append('<div id="mastodon-like-count"><a href="' + stats.url + '"><span title="Likes"><i class="fa fa-star"></i>' + stats.favs + '</span></a></div></div>'); $("#like-count-container").append('<div id="mastodon-like-count"><a href="' + stats.url + '"><span title="Likes"><i class="fa fa-star"></i>' + stats.favs + '</span></a></div></div>');
$("#reblog-count-container").append('<div id="mastodon-reblog-count"><a href="' + stats.url + '"><span title="Reblogs"><i class="fa fa-retweet"></i>' + stats.reblogs + '</span></a></div></div>'); $("#reblog-count-container").append('<div id="mastodon-reblog-count"><a href="' + stats.url + '"><span title="Reblogs"><i class="fa fa-retweet"></i>' + stats.reblogs + '</span></a></div></div>');
$("#reply-count-container").append('<div id="mastodon-reply-count"><a href="' + stats.url + '"><span title="Comments"><i class="fa fa-comments"></i>' + stats.replies + '</span></a></div></div>'); $("#reply-count-container").append('<div id="mastodon-reply-count"><a href="' + stats.url + '"><span title="Comments"><i class="fa fa-comments"></i>' + stats.replies + '</span></a></div></div>');
var comments = data.comments; var comments = data.comments;
var commentCount = Object.keys(comments).length;
debugLog('Processing ' + commentCount + ' comments');
$.each(comments, function(key, value) { $.each(comments, function(key, value) {
debugLog('Adding comment: ' + key, value);
var timestamp = Date.parse(value.date); var timestamp = Date.parse(value.date);
var date = new Date(timestamp); var date = new Date(timestamp);
var comment = "<div class='comment' id='" + key + "'>"; var comment = "<div class='comment' id='" + key + "'>";
@@ -39,19 +76,28 @@ $(document).ready(function() {
var parentComment = document.getElementById(value.reply_to); var parentComment = document.getElementById(value.reply_to);
if (value.reply_to === root || parentComment === null) { if (value.reply_to === root || parentComment === null) {
$("#comments").append(comment); $("#comments").append(comment);
debugLog(' → Added as top-level comment');
} else { } else {
var selector = '#'+value.reply_to; var selector = '#'+value.reply_to;
$(selector).append(comment); $(selector).append(comment);
debugLog(' → Added as reply to: ' + value.reply_to);
} }
}); });
if (parseInt(root) > 0) { if (parseInt(root) > 0) {
$("#reference").append("<a href='" + MastodonUser + "/statuses/" + root + "'>Join the discussion on Mastodon!</a>"); $("#reference").append("<a href='" + MastodonUser + "/statuses/" + root + "'>Join the discussion on Mastodon!</a>");
debugLog('Added Mastodon discussion link for root: ' + root);
} else { } else {
$("#comments").empty(); $("#comments").empty();
$("#statistics").empty(); $("#statistics").empty();
$("#reference").append("Comments are handled by my <a href='" + MastodonUser + "'>Mastodon account</a>. Sadly this article wasn't published at Mastodon. Feel free to <a href='" + CommentsContact + "'>send me a mail</a> if you want to share your thoughts regarding this topic."); $("#reference").append("Comments are handled by my <a href='" + MastodonUser + "'>Mastodon account</a>. Sadly this article wasn't published at Mastodon. Feel free to <a href='" + CommentsContact + "'>send me a mail</a> if you want to share your thoughts regarding this topic.");
debugLog('No Mastodon toot found for this article');
} }
},
error: function(xhr, status, error) {
console.error('[Mastodon Comments] AJAX Error:', status, error);
console.error('[Mastodon Comments] Response:', xhr.responseText);
} }
}); });
}); });

View File

@@ -10,13 +10,30 @@ $instance = $config['mastodon-instance'];
$uid = $config['user-id']; $uid = $config['user-id'];
$searchurl = $config['search-url']; $searchurl = $config['search-url'];
$search = isset($_GET['search']) ? strtolower($_GET['search']) : ''; $search = isset($_GET['search']) ? strtolower($_GET['search']) : '';
$toot_url = isset($_GET['toot_url']) ? $_GET['toot_url'] : null;
$force_refresh = isset($_GET['force_refresh']) && $_GET['force_refresh'] == '1';
$debug_on = $config['debug']; $debug_on = $config['debug'];
debug("Request parameters - search: $search, toot_url: " . ($toot_url ? $toot_url : "not provided") . ", force_refresh: " . ($force_refresh ? "yes" : "no"));
/* cache files */ /* cache files */
$ctt = $config['cache_toots']; $ctt = $config['cache_toots'];
$dbt = "cache-toots.json"; $dbt = "cache-toots.json";
$ctc = $config['cache_comments']; $ctc = $config['cache_comments'];
$dbc = "cache-comments_%id.json"; $dbc = "cache-comments_%id.json";
/* Force cache refresh if requested */
if ($force_refresh) {
debug("Force refresh requested - clearing toot cache");
if (file_exists($dbt)) {
unlink($dbt);
}
// Also clear all comment cache files
foreach (glob("cache-comments_*.json") as $cache_file) {
unlink($cache_file);
debug("Deleted cache file: $cache_file");
}
}
/* Exit if search empty */ /* Exit if search empty */
if (empty($search)) { if (empty($search)) {
debug("No proper search given"); debug("No proper search given");
@@ -31,6 +48,19 @@ function debug($data) {
} }
} }
/* Parse Mastodon toot URL to extract instance and toot ID */
function parseTootUrl($url) {
// Expected format: https://instance.example/@username/1234567890
// or: https://instance.example/users/username/statuses/1234567890
if (preg_match('|^(https?://[^/]+)/.*/(\d+)$|i', $url, $matches)) {
return [
'instance' => $matches[1],
'id' => $matches[2]
];
}
return null;
}
/* CACHE FUNCTIONS */ /* CACHE FUNCTIONS */
/* write data to file */ /* write data to file */
function write_db($db, $data, $id) { function write_db($db, $data, $id) {
@@ -40,8 +70,8 @@ function write_db($db, $data, $id) {
} }
$file['toots'] = $data; $file['toots'] = $data;
$file['timestamp'] = time(); $file['timestamp'] = time();
// encode and write file // encode and write file (no pretty print for performance)
$encoded = json_encode($file, JSON_PRETTY_PRINT); $encoded = json_encode($file);
file_put_contents($db, $encoded, LOCK_EX); file_put_contents($db, $encoded, LOCK_EX);
} }
/* delete file */ /* delete file */
@@ -81,22 +111,25 @@ function read_db($db, &$data, $cachetime, &$cachebreak, $id) {
/* TOOT FUNCTIONS */ /* TOOT FUNCTIONS */
function collectToots($instance, $uid, $min_id, $searchurl) { function collectToots($instance, $uid, $min_id, $searchurl) {
$raw = file_get_contents("$instance/api/v1/accounts/$uid/statuses?exclude_reblogs=true&exclude_replies=true&limit=50&min_id=$min_id"); $raw = @file_get_contents("$instance/api/v1/accounts/$uid/statuses?exclude_reblogs=true&exclude_replies=true&limit=50&min_id=$min_id");
if ($raw === false) {
debug("Failed to fetch toots from API");
return array();
}
$json_complete = json_decode($raw, true); $json_complete = json_decode($raw, true);
$json = array(); $json = array();
foreach ($json_complete as $toot) { foreach ($json_complete as $toot) {
$json[] = array('id' => $toot['id'], 'date' => $toot['created_at'] ,'url' => analyzeToot($instance, $toot['id'], $searchurl)); $url = analyzeToot($toot['content'], $toot['id'], $searchurl);
$json[] = array('id' => $toot['id'], 'date' => $toot['created_at'] ,'url' => $url);
} }
return($json); return($json);
} }
/* Find out if a toot contains the searched URL */ /* Find out if a toot contains the searched URL */
function analyzeToot($instance, $id, $searchurl) { function analyzeToot($content, $id, $searchurl) {
debug("Searching for $searchurl in $id"); debug("Searching for $searchurl in $id");
$raw = file_get_contents("$instance/api/v1/statuses/$id");
$json = json_decode($raw, true);
// search for $searchurl inside of <a> tags, until (and excluding) a " // search for $searchurl inside of <a> tags, until (and excluding) a "
preg_match("|$searchurl.+?(?=\")|i", $json['content'], $matches); preg_match("|$searchurl.+?(?=\")|i", $content, $matches);
if(!empty($matches)) { if(!empty($matches)) {
return(strtolower($matches[0])); // take first match inside toot return(strtolower($matches[0])); // take first match inside toot
@@ -123,33 +156,29 @@ function filterComments($descendants, $root, &$result) {
} }
return $result; return $result;
} }
/* get /context of toot */ /* get /context of toot and extract stats - combined to reduce API calls */
function tootContext($instance, $id, &$result) { function tootContextAndStats($instance, $id, &$result) {
$raw = file_get_contents("$instance/api/v1/statuses/$id/context"); debug("Fetching context and stats for ID $id");
$json = json_decode($raw, true);
filterComments($json['descendants'], $id, $result); // Fetch context (descendants/replies)
$raw_context = @file_get_contents("$instance/api/v1/statuses/$id/context");
if ($raw_context !== false) {
$json_context = json_decode($raw_context, true);
filterComments($json_context['descendants'], $id, $result);
} else {
debug("Failed to fetch context for $id");
} }
/* extract stats info from toot */
function filterStats($stats) { // Fetch stats
$result = [ $raw_stats = @file_get_contents("$instance/api/v1/statuses/$id");
'reblogs' => (int)$stats['reblogs_count'], if ($raw_stats !== false) {
'favs' => (int)$stats['favourites_count'], $json_stats = json_decode($raw_stats, true);
'replies' => (int)$stats['replies_count'], $result['stats']['reblogs'] = (int)$json_stats['reblogs_count'];
'url' => $stats['url'] $result['stats']['favs'] = (int)$json_stats['favourites_count'];
]; $result['stats']['replies'] = (int)$json_stats['replies_count'];
return $result; $result['stats']['url'] = $json_stats['url'];
} } else {
/* for toot, extract interesting statistics */ debug("Failed to fetch stats for $id");
function tootStats($instance, $id, &$result) {
debug("Checking ID $id");
$raw = file_get_contents("$instance/api/v1/statuses/$id");
$json = json_decode($raw, true);
$newStats = filterStats($json);
$result['stats']['reblogs'] += $newStats['reblogs'];
$result['stats']['favs'] += $newStats['favs'];
$result['stats']['replies'] += $newStats['replies'];
if (empty($result['stats']['url'])) {
$result['stats']['url'] = $newStats['url'];
} }
} }
@@ -157,9 +186,38 @@ function tootStats($instance, $id, &$result) {
* START PROGRAM * START PROGRAM
***************/ ***************/
// create empty $result template
$result_empty = ['comments' => [], 'stats' => ['reblogs' => 0, 'favs' => 0, 'replies' => 0, 'url' => '', 'root' => 0]];
$result = $result_empty;
/* If toot_url is provided, skip the toot search entirely */
$toot_instance = $instance; // Default to config instance
$id = null; // Initialize
if ($toot_url) {
$parsed = parseTootUrl($toot_url);
if ($parsed) {
$id = $parsed['id'];
$toot_instance = $parsed['instance'];
debug("Toot URL provided: $toot_url");
debug("Extracted - Instance: $toot_instance, ID: $id");
// When using a custom instance, include instance hash in cache filename to avoid collisions
if ($toot_instance !== $instance) {
$instance_hash = md5($toot_instance);
$dbc = "cache-comments_%id-{$instance_hash}.json";
debug("Using instance-specific cache file for non-default instance");
}
} else {
debug("Failed to parse toot URL: $toot_url");
$toot_url = null; // Fall back to search
}
}
if (!$toot_url) {
/* check whether the cached file containing all toots is older than max. cache time */ /* check whether the cached file containing all toots is older than max. cache time */
// this at the same time loads the cached DB, either way // this at the same time loads the cached DB, either way
$cachebreak = false; $cachebreak = $force_refresh; // Force cache break if requested
read_db($dbt, $toots, $ctt, $cachebreak, false); read_db($dbt, $toots, $ctt, $cachebreak, false);
if ($cachebreak) { if ($cachebreak) {
@@ -197,24 +255,23 @@ if ($cachebreak) {
debug("Toots cache is up-to-date"); debug("Toots cache is up-to-date");
} }
// create empty $result
$result_empty = ['comments' => [], 'stats' => ['reblogs' => 0, 'favs' => 0, 'replies' => 0, 'url' => '', 'root' => 0]];
$result = $result_empty;
/* check if URL from $search exists in $toots */ /* check if URL from $search exists in $toots */
$id = array_keys( $found_id = null;
array_filter( foreach ($toots as $toot) {
array_column($toots, 'url'), if (!empty($toot['url']) && strpos($toot['url'], $search) !== false) {
function ($value) use ($search) { $found_id = $toot['id']; // will keep the oldest (last in array)
return (strpos($value, $search) !== false);
} }
) }
);
if (empty($id)) { if ($found_id === null) {
debug("Blog URL \"$search\" has not been found"); debug("Blog URL \"$search\" has not been found");
} else { } else {
// if multiple toots with the searched URL exist, take the oldest one (largest array index) $id = $found_id;
$id = $toots[end($id)]['id']; }
}
/* Process comments if we have a toot ID (either from URL or search) */
if ($id) {
/* read cached comments, or reload new comments if cached data too old */ /* read cached comments, or reload new comments if cached data too old */
$cachebreak = false; $cachebreak = false;
@@ -228,9 +285,8 @@ if (empty($id)) {
$result = $result_empty; $result = $result_empty;
read_db($dbc, $result, $ctc, $cachebreak, $id); read_db($dbc, $result, $ctc, $cachebreak, $id);
/* Extract comments and stats from toot */ /* Extract comments and stats from toot */
tootContext($instance, $id, $result); tootContextAndStats($toot_instance, $id, $result);
tootStats($instance, $id, $result); // Always count replies manually for accuracy
// FIXME: At the moment the API doesn't return the correct replies count so I count it manually
$result['stats']['replies'] = count($result['comments']); $result['stats']['replies'] = count($result['comments']);
$result['stats']['root'] = $id; $result['stats']['root'] = $id;
@@ -247,6 +303,9 @@ header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
// headers to tell that result is JSON // headers to tell that result is JSON
header('Content-type: application/json'); header('Content-type: application/json');
// add debug flag to result for JavaScript console logging
$result['debug'] = $debug_on;
// actually output result as JSON, to be digested by getcomments.js // actually output result as JSON, to be digested by getcomments.js
echo json_encode($result); echo json_encode($result);