🏠 Home
Coming Out / My Journey How did I know How I figured it out My name Favourite coming out story Coming out at work as Trans Coming out to the local PSAC Coming out Trans to co-workers First time I used the women's washroom alone Last time I was in the guys washroom Second last time I was in the guys washroom Thinking about "the surgery" Bathroom Block Change Room Ignorance in the Washroom The Shape of Ones Genitals Trans Broken Arm Syndrome Trans related sexual Harassment and Assault UNE Gender Protocols
Human Rights Human Rights (2018) Human Rights March 2019 (1 of 2) Human Rights March 2019 (2 of 2) Concerns Regarding Recent Appointments to the Saskatchewan Human Rights Commission Scott Moe's abuse of power
Open Letters & Advocacy F-35 letter Open Letter Regarding Double Standards in Distracted Driving Open Letter Sask Health Open Letter to A&W Open Letter to Arby's Open Letter to Swim Co Open Letter to WestJet Paradigm Quest Inc o/a Merix second open letter Paradigm Quest Inc. O/A Merix Open Letter Request to Review Use of US-Owned Social Media Platforms by Elected Officials Review Request – Use of US-Owned Social Media Platforms by Saskatchewan Politicians Urgent Request Regarding Saskatchewan Politicians' Use of US-Owned Social Media Platforms Ethical and Governance Concerns Regarding Politicians' Use of US-Owned Social Media Platforms Saskatchewan Legislative Assembly and the Use of US Social Media Platforms Statistics Canada Open Letter Land Titles – Open Letter ISC Lablaws Letters to government June 17th Minister of education Your MLA
COVID-19 COVID-19 Co-op COVID-19 Dollarama COVID-19 Staples COVID-19 Walmart
Other Drivers and Health Cards across Canada My current journey with Montreal Redevelopment Research in to forms for the Government of Canada Sample Page Waiting for Scheduling Links

Vibe coded the post to Mastodon

I have vibe coded this publish to Mastodon, after spending time with Ghost and moving my domain around. So much fun! On top of that I have been doing all kinds of other things on my home lab, I have most of my music now all categorized and in Navidrome. I am sure there are other projects I have done, or tried and gave up on (like a site visitor counter to email me every morning, as that will be one feature I will miss from WordPress). I am however feeling rather accomplished! I even added a new image to hopefully replace that purple shaded box underneath my Mastodon posts! I am going to try and figure out how to put this vibe coded transformation onto my side bar but if I cannot figure it out please find the instructions below. Sorry for the titles those should be comments with the # before them.

Using Mastodon as a Comment System for Ghost (No Membership Signup Required)

Ghost has a native commenting system, but it requires readers to create a
member account on your specific site before they can comment — not
everyone wants that friction, and I didn't either.

Since I already post to Mastodon and use it actively, I built a system
that uses Mastodon replies as my blog's comments instead. No account on
my site required — just an existing (or brand new, free) Mastodon
account, which a lot of readers already have.

Here's the full system, in case it's useful to anyone else running Ghost.

The high-level idea

  1. When I publish a new post, a small service automatically posts it to
    my Mastodon account.
  2. That same service writes the resulting Mastodon post's ID back into
    the specific blog post it came from.
  3. A small script on each post page reads that ID, fetches any replies
    to that Mastodon post, and displays them as comments — with a link
    inviting readers to reply on Mastodon to join the conversation.

Three moving pieces, each fairly small on its own.

Piece 1: Auto-posting to Mastodon on publish

This is a tiny Node.js/Express service that listens for a webhook from
Ghost and posts to Mastodon when it fires.

In Ghost: Settings → Integrations → Add custom integration → give it
a name → scroll to Webhooks → Add webhook, with:

  • Event: Post published
  • Target URL: wherever this service is reachable (e.g.
    http://your-server:3010/webhook)

The service itself (server.js):

const express = require('express');
const app = express();
app.use(express.json({ limit: '10mb' }));

const MASTODON_INSTANCE = process.env.MASTODON_INSTANCE;
const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN;

const EXCERPT_LENGTH = 250;

function stripHtml(html) {
  return html
    .replace(/<[^>]*>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&#39;/g, "'")
    .replace(/&quot;/g, '"')
    .replace(/\s+/g, ' ')
    .trim();
}

function buildExcerpt(post) {
  let text = post.excerpt && post.excerpt.trim().length > 0
    ? post.excerpt.trim()
    : stripHtml(post.html || '');

  if (text.length > EXCERPT_LENGTH) {
    text = text.slice(0, EXCERPT_LENGTH).trim() + '...';
  }
  return text;
}

app.post('/webhook', async (req, res) => {
  try {
    const post = req.body.post?.current;
    if (!post) return res.status(400).send('No post data');

    const excerpt = buildExcerpt(post);
    const statusText = `${post.title}\n\n${excerpt}\n\nRead more: ${post.url}`;

    const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${MASTODON_ACCESS_TOKEN}`
      },
      body: JSON.stringify({ status: statusText })
    });

    const mastoData = await mastoResponse.json();
    console.log('Mastodon response:', mastoResponse.status, mastoData.url || mastoData.error);

    res.status(200).send('OK');
  } catch (err) {
    console.error('Error in webhook handler:', err);
    res.status(500).send('Error');
  }
});

app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));

You'll need a Mastodon access token — create it under Settings →
Development
on your Mastodon instance, with just the write:statuses
permission checked.

Piece 2: Linking each post back to its Mastodon thread

This is where it gets more useful. Rather than just posting to
Mastodon, the service also writes the resulting post's ID back into
the Ghost post itself, so the page knows which thread to pull replies
from later.

This uses Ghost's official Admin API library
(@tryghost/admin-api), which handles the JWT authentication for you —
worth using this rather than hand-rolling it, since a common mistake
(passing the API secret as a raw string instead of hex-decoding it
first) produces a signature that looks valid but silently fails.

const express = require('express');
const GhostAdminAPI = require('@tryghost/admin-api');
const app = express();
app.use(express.json({ limit: '10mb' }));

const MASTODON_INSTANCE = process.env.MASTODON_INSTANCE;
const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN;

const EXCERPT_LENGTH = 250;

const ghostApi = new GhostAdminAPI({
  url: process.env.GHOST_URL,
  key: process.env.GHOST_ADMIN_API_KEY,
  version: 'v5.0'
});

function stripHtml(html) {
  return html
    .replace(/<[^>]*>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&#39;/g, "'")
    .replace(/&quot;/g, '"')
    .replace(/\s+/g, ' ')
    .trim();
}

function buildExcerpt(post) {
  let text = post.excerpt && post.excerpt.trim().length > 0
    ? post.excerpt.trim()
    : stripHtml(post.html || '');

  if (text.length > EXCERPT_LENGTH) {
    text = text.slice(0, EXCERPT_LENGTH).trim() + '...';
  }
  return text;
}

app.post('/webhook', async (req, res) => {
  try {
    const post = req.body.post?.current;
    if (!post) return res.status(400).send('No post data');

    const excerpt = buildExcerpt(post);
    const statusText = `${post.title}\n\n${excerpt}\n\nRead more: ${post.url}`;

    const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${MASTODON_ACCESS_TOKEN}`
      },
      body: JSON.stringify({ status: statusText })
    });

    const mastoData = await mastoResponse.json();
    console.log('Mastodon response:', mastoResponse.status, mastoData.url || mastoData.error);

    if (mastoResponse.status === 200 && mastoData.id) {
      try {
        await ghostApi.posts.edit({
          id: post.id,
          updated_at: post.updated_at,
          codeinjection_foot: `<script>window.mastodonPostId = "${mastoData.id}";</script>`
        });
        console.log('Wrote Mastodon post ID back to Ghost post:', post.id);
      } catch (ghostErr) {
        console.error('Failed to update Ghost post with Mastodon ID:', ghostErr);
      }
    } else {
      console.log('Skipped Ghost update — condition not met. Status:', mastoResponse.status, 'ID present:', !!mastoData.id);
    }

    res.status(200).send('OK');
  } catch (err) {
    console.error('Error in webhook handler:', err);
    res.status(500).send('Error');
  }
});

app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));

You'll need a Ghost Admin API key too — same Integrations screen,
scroll up from the webhooks section. It comes as id:secret, both
needed as-is (the library handles the encoding).

A note on post dates: this edit does not change your post's
published date. Ghost does separately track a "last updated" timestamp
that any edit refreshes — whether that's visibly shown to readers
depends entirely on your theme.

Piece 3: Displaying the replies as comments

A script that reads the ID set in Piece 2, fetches replies from
Mastodon's public API, and renders them into the page. This lives
entirely in Ghost's Code Injection (Settings → Code injection — either
the Site Header or Footer both work, since the script waits for the
page to finish loading either way) — no separate file needed, since
Ghost doesn't have a clean, reliable way to serve arbitrary static files
on its own.

<script>
document.addEventListener('DOMContentLoaded', function() {
  if (typeof window.mastodonPostId === 'undefined') return;

  const MASTODON_INSTANCE = 'https://your-instance.example';
  const postId = window.mastodonPostId;

  fetch(`${MASTODON_INSTANCE}/api/v1/statuses/${postId}/context`)
    .then(res => res.json())
    .then(data => {
      const replies = data.descendants || [];
      const container = document.createElement('div');
      container.className = 'mastodon-comments';

      const heading = document.createElement('h3');
      heading.textContent = replies.length > 0
        ? `${replies.length} comment${replies.length === 1 ? '' : 's'} via Mastodon`
        : 'Comments via Mastodon';
      container.appendChild(heading);

      const link = document.createElement('a');
      link.href = `${MASTODON_INSTANCE}/@yourhandle/${postId}`;
      link.target = '_blank';
      link.rel = 'noopener';
      link.textContent = 'Reply on Mastodon to join the conversation';
      link.style.display = 'block';
      link.style.marginBottom = '20px';
      container.appendChild(link);

      replies.forEach(reply => {
        const item = document.createElement('div');
        item.className = 'mastodon-comment';
        item.style.borderTop = '1px solid #ddd';
        item.style.padding = '12px 0';

        const author = document.createElement('strong');
        author.textContent = reply.account.display_name || reply.account.username;
        item.appendChild(author);

        const content = document.createElement('div');
        content.innerHTML = reply.content;
        item.appendChild(content);

        container.appendChild(item);
      });

      const target = document.querySelector('.gh-content') || document.querySelector('article') || document.body;
      target.appendChild(container);
    })
    .catch(err => console.error('Failed to load Mastodon comments:', err));
});
</script>

Backfilling old posts (without spamming your followers)

If you're adding this to a blog that already has a back catalog, you'll
want existing posts to have comment threads too — but posting all of
them to your main account at once would flood your followers' feeds
with a sudden burst of old content.

The fix: run the backfill from a separate, dedicated Mastodon
account
with no real followers, rather than your main one. New posts
going forward still go through your main account exactly as normal;
only the retroactive backfill uses the second account.

const GhostAdminAPI = require('@tryghost/admin-api');

const MASTODON_INSTANCE = 'https://your-instance.example';
const BACKFILL_ACCESS_TOKEN = process.env.BACKFILL_ACCESS_TOKEN;

const ghostApi = new GhostAdminAPI({
  url: process.env.GHOST_URL,
  key: process.env.GHOST_ADMIN_API_KEY,
  version: 'v5.0'
});

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function backfill() {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const posts = await ghostApi.posts.browse({ page, limit: 15, fields: 'id,title,url,updated_at,codeinjection_foot' });

    for (const post of posts) {
      if (post.codeinjection_foot && post.codeinjection_foot.includes('mastodonPostId')) {
        console.log(`Skipping "${post.title}" — already linked`);
        continue;
      }

      const statusText = `${post.title}\n\n${post.url}`;

      try {
        const mastoResponse = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${BACKFILL_ACCESS_TOKEN}`
          },
          body: JSON.stringify({ status: statusText, visibility: 'unlisted' })
        });

        const mastoData = await mastoResponse.json();

        if (mastoResponse.status === 200 && mastoData.id) {
          await ghostApi.posts.edit({
            id: post.id,
            updated_at: post.updated_at,
            codeinjection_foot: `<script>window.mastodonPostId = "${mastoData.id}";</script>`
          });
          console.log(`Linked "${post.title}" -> ${mastoData.url}`);
        } else {
          console.error(`Failed to post "${post.title}":`, mastoData.error);
        }
      } catch (err) {
        console.error(`Error processing "${post.title}":`, err);
      }

      await sleep(3000);
    }

    hasMore = posts.meta.pagination.pages > page;
    page++;
  }

  console.log('Backfill complete.');
}

backfill();

Note the skip if already linked check at the top of the loop — this
makes it safe to re-run if it fails partway through, and the
visibility: 'unlisted' keeps these backfill posts out of public
timelines (though worth knowing: Unlisted still shows up in a
follower's home feed exactly like a public post — the dedicated
account with no followers is what actually solves the spam problem, not
the visibility setting alone).

I'd recommend testing against a single post first before running it
against your whole history — same script, just pointed at one specific
post by its slug via ghostApi.posts.read({ slug: 'your-post-slug' })
instead of the full browse loop.

Known limitations

Mastodon will show a link preview card under every post, and you
can't disable or resize it.
This is a genuine, current Mastodon
limitation, not something specific to this setup — there are open,
unresolved feature requests asking for exactly this. What you can
control is which image shows in that card: it pulls from your blog's
og:image tag, so setting a custom cover image in your CMS's settings
(rather than leaving whatever generic default it ships with) changes
what appears there.

Replies only show up on posts made after this was set up, unless
you run the backfill for older content.

This depends on Mastodon's API remaining stable — like any
integration built against a third-party API, future changes on their
end could require adjustments here.

Why this approach

I didn't want to require readers to create an account just to leave a
comment, and I didn't want a third-party embedded comment widget either
(most come with tracking, ads, or a company that could shut down or get
acquired). This keeps commenting genuinely decentralized — replies live
on Mastodon itself, under the commenter's own control, not locked into
my specific site.