Compare commits

...

4 Commits

Author SHA1 Message Date
330925edce feat: add more logging to JS 2026-02-23 17:38:01 +01:00
40508c883e doc: add README 2026-02-23 17:34:27 +01:00
0764bba221 feat: add a cache breaking route 2026-02-23 17:34:19 +01:00
a749a6b6aa feat: massive performance and logic improvements 2026-02-23 17:17:53 +01:00
4 changed files with 199 additions and 50 deletions

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# 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
### 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>

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,6 +26,8 @@ $(document).ready(function() {
return; return;
} }
debugLog('Searching for comments for: ' + RelPermalink);
$.ajax({ $.ajax({
url: "/comments/getcomments.php", url: "/comments/getcomments.php",
type: "get", type: "get",
@@ -21,13 +35,30 @@ $(document).ready(function() {
search : RelPermalink 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 +70,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,6 +10,7 @@ $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']) : '';
$force_refresh = isset($_GET['force_refresh']) && $_GET['force_refresh'] == '1';
$debug_on = $config['debug']; $debug_on = $config['debug'];
/* cache files */ /* cache files */
$ctt = $config['cache_toots']; $ctt = $config['cache_toots'];
@@ -17,6 +18,19 @@ $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");
@@ -40,8 +54,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 +95,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 +140,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'];
} }
} }
@@ -159,7 +172,7 @@ function tootStats($instance, $id, &$result) {
/* 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) {
@@ -202,19 +215,17 @@ $result_empty = ['comments' => [], 'stats' => ['reblogs' => 0, 'favs' => 0, 'rep
$result = $result_empty; $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'];
/* 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 +239,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($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 +257,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);