Table of Contents
Overview
In the previous post GitHub Pages Deployment, we covered the deployment process. In this post, we’ll explain the script system that automatically shares blog posts to various social media platforms.
After publishing a new blog post, you need to manually share it one by one to multiple SNS platforms like Facebook, LinkedIn, Threads, Bluesky, Mastodon, etc. This task turns out to be quite tedious. Especially for a multilingual blog, the number of shares multiplies since you need to share for each language. To automate this repetitive task, we developed a Node.js script that detects new posts based on RSS feeds and shares them to multiple platforms at once.
In this post, we share all the code actually in use and explain the role and implementation of each module in detail.
Script Structure
The social share system is organized into modules within the scripts/ directory.
scripts/
├── share.mjs # Main entry point (CLI)
├── share-status.json # Share status tracking file
└── lib/
├── rss-reader.mjs # RSS feed parsing
├── status-manager.mjs # Share status management
├── formatter.mjs # Platform-specific message formatter
├── logger.mjs # Color logger
└── platforms/
├── facebook.mjs # Facebook Graph API
├── linkedin.mjs # LinkedIn API
├── threads.mjs # Threads API
├── bluesky.mjs # Bluesky AT Protocol API
└── mastodon.mjs # Mastodon API
The overall flow is as follows:
rss-reader.mjsextracts the post list from the built RSS feedsstatus-manager.mjscompares withshare-status.jsonto register new postsshare.mjsfinds unshared posts and calls each platform module’spost()function- Each platform module formats the message using
formatter.mjsand then calls the API
Available npm Scripts
These are the share-related scripts registered in package.json.
{
"scripts": {
"share:sync": "node scripts/share.mjs sync",
"share:status": "node scripts/share.mjs status",
"share:status:latest": "node scripts/share.mjs status --pending --latest 3",
"share:status:oldest": "node scripts/share.mjs status --pending --oldest 3",
"share": "node scripts/share.mjs post",
"share:oldest": "node scripts/share.mjs post --oldest",
"share:latest": "node scripts/share.mjs post --latest"
}
}
RSS Feed Parsing — rss-reader.mjs
This is the module that extracts post information from the built RSS feeds. It reads the language-specific RSS feeds (feed.xml, ko/feed.xml, en/feed.xml) and groups posts by permalink. Since a single post can have 3 language versions, the versions object stores the title/URL/description for each language.
// scripts/lib/rss-reader.mjs
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const SITE_URL = 'https://deku.posstree.com';
const FEEDS = [
{ lang: 'ja', path: 'dist/feed.xml' },
{ lang: 'ko', path: 'dist/ko/feed.xml' },
{ lang: 'en', path: 'dist/en/feed.xml' },
];
function decodeXmlEntities(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'");
}
function parseItems(xml) {
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const content = match[1];
const get = (tag) => {
const m = content.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
return m ? decodeXmlEntities(m[1].trim()) : '';
};
items.push({
title: get('title'),
link: get('link'),
description: get('description'),
pubDate: get('pubDate'),
});
}
return items;
}
function extractPermalink(url, lang) {
const path = url.replace(SITE_URL, '');
if (lang === 'ko') return path.replace(/^\/ko/, '');
if (lang === 'en') return path.replace(/^\/en/, '');
return path;
}
function extractCategory(permalink) {
const parts = permalink.split('/').filter(Boolean);
return parts.length > 0 ? parts[0] : '';
}
export function readAllFeeds(projectRoot) {
const missing = [];
const feedData = {};
for (const { lang, path } of FEEDS) {
const fullPath = resolve(projectRoot, path);
if (!existsSync(fullPath)) {
missing.push(path);
continue;
}
const xml = readFileSync(fullPath, 'utf-8');
feedData[lang] = parseItems(xml);
}
if (missing.length === FEEDS.length) {
return { error: 'no-feeds', missing };
}
// Group language-specific posts by permalink
const postMap = new Map();
for (const { lang } of FEEDS) {
const items = feedData[lang];
if (!items) continue;
for (const item of items) {
const permalink = extractPermalink(item.link, lang);
if (!postMap.has(permalink)) {
postMap.set(permalink, {
permalink,
date: new Date(item.pubDate).toISOString().split('T')[0],
category: extractCategory(permalink),
versions: {},
});
}
postMap.get(permalink).versions[lang] = {
title: item.title,
url: item.link,
description: item.description,
};
}
}
const posts = Array.from(postMap.values()).sort(
(a, b) => new Date(b.date) - new Date(a.date)
);
return { posts, missing };
}
XML parsing is handled with regex without any external library. Since the RSS feed structure is simple, this approach is sufficient.
Share Status Management — status-manager.mjs
This module tracks which platforms each post has been shared to. It stores the status in a share-status.json file and provides functions for syncing new posts, querying unshared items, and marking shares as complete.
// scripts/lib/status-manager.mjs
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const BASE_PLATFORMS = ['facebook', 'linkedin', 'threads', 'bluesky'];
let _platforms = null;
export function getPlatforms() {
if (_platforms) return _platforms;
const platforms = [...BASE_PLATFORMS];
// Auto-detect Mastodon instances from environment variables
for (const key of Object.keys(process.env)) {
const match = key.match(/^MASTODON_(.+)_INSTANCE$/);
if (match) platforms.push(`mastodon:${match[1].toLowerCase()}`);
}
_platforms = platforms;
return platforms;
}
function getStatusPath(projectRoot) {
return resolve(projectRoot, 'scripts/share-status.json');
}
export function loadStatus(projectRoot) {
const path = getStatusPath(projectRoot);
if (!existsSync(path)) return [];
return JSON.parse(readFileSync(path, 'utf-8'));
}
export function saveStatus(projectRoot, status) {
const path = getStatusPath(projectRoot);
writeFileSync(path, JSON.stringify(status, null, 2) + '\n', 'utf-8');
}
export function syncStatus(projectRoot, feedPosts) {
const status = loadStatus(projectRoot);
const existing = new Set(status.map((s) => s.permalink));
let added = 0;
// Add keys to existing entries when new platforms are added
for (const entry of status) {
for (const p of getPlatforms()) {
if (!entry.shared[p]) entry.shared[p] = [];
}
}
for (const post of feedPosts) {
if (existing.has(post.permalink)) {
// Update existing entries when new languages are added
const entry = status.find((s) => s.permalink === post.permalink);
for (const [lang, version] of Object.entries(post.versions)) {
if (!entry.versions[lang]) {
entry.versions[lang] = version;
}
}
continue;
}
// Add new post — all platforms start as "unshared"
status.push({
permalink: post.permalink,
date: post.date,
category: post.category,
versions: post.versions,
shared: Object.fromEntries(getPlatforms().map((p) => [p, []])),
});
added++;
}
// Sort by date descending and save
status.sort((a, b) => new Date(b.date) - new Date(a.date));
saveStatus(projectRoot, status);
return { added, total: status.length };
}
export function getUnshared(
status,
{ post, only, oldest, latest, platforms: platformOverride } = {}
) {
let entries = post ? status.filter((s) => s.permalink === post) : status;
const allPlatforms = platformOverride || getPlatforms();
const platforms = only
? allPlatforms.filter((p) => p === only || p.startsWith(`${only}:`))
: allPlatforms;
if (oldest || latest) {
// Filter only entries with unshared languages
const unsharedEntries = entries.filter((entry) =>
platforms.some((platform) => {
if (!entry.shared[platform]) return false;
const langs = Object.keys(entry.versions);
return langs.some((lang) => !entry.shared[platform].includes(lang));
})
);
if (unsharedEntries.length > 0) {
// Status is sorted by date descending, so last = oldest, first = latest
const target = oldest
? unsharedEntries[unsharedEntries.length - 1]
: unsharedEntries[0];
entries = [target];
} else {
entries = [];
}
}
const tasks = [];
for (const entry of entries) {
for (const platform of platforms) {
if (!entry.shared[platform]) continue;
const langs = Object.keys(entry.versions);
const unsharedLangs = langs.filter(
(lang) => !entry.shared[platform].includes(lang)
);
if (unsharedLangs.length > 0) {
tasks.push({ entry, platform, langs: unsharedLangs });
}
}
}
return tasks;
}
export function markShared(projectRoot, permalink, platform, lang) {
const status = loadStatus(projectRoot);
const entry = status.find((s) => s.permalink === permalink);
if (!entry) return;
if (!entry.shared[platform]) entry.shared[platform] = [];
if (!entry.shared[platform].includes(lang)) {
entry.shared[platform].push(lang);
}
saveStatus(projectRoot, status);
}
The structure of share-status.json is as follows. It manages language version information and the list of shared languages per platform for each post.
[
{
"permalink": "/astro/migration-reason/",
"date": "2026-03-24",
"category": "astro",
"versions": {
"ja": {
"title": "...",
"url": "https://deku.posstree.com/astro/migration-reason/",
"description": "..."
},
"ko": {
"title": "...",
"url": "https://deku.posstree.com/ko/astro/migration-reason/",
"description": "..."
},
"en": {
"title": "...",
"url": "https://deku.posstree.com/en/astro/migration-reason/",
"description": "..."
}
},
"shared": {
"facebook": ["ja", "ko"],
"linkedin": ["ja", "ko", "en"],
"threads": [],
"bluesky": ["ja"],
"mastodon:social": []
}
}
]
Main CLI — share.mjs
share.mjs is the CLI entry point that handles three commands: sync, status, and post. It combines the modules described above to perform the actual sharing work.
// scripts/share.mjs
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import { readAllFeeds } from './lib/rss-reader.mjs';
import {
loadStatus,
syncStatus,
getUnshared,
markShared,
} from './lib/status-manager.mjs';
import {
formatFacebook,
formatLinkedIn,
formatThreads,
formatBluesky,
formatMastodon,
} from './lib/formatter.mjs';
import { log } from './lib/logger.mjs';
import * as facebook from './lib/platforms/facebook.mjs';
import * as linkedin from './lib/platforms/linkedin.mjs';
import * as threads from './lib/platforms/threads.mjs';
import * as bluesky from './lib/platforms/bluesky.mjs';
import { discoverPlatforms as discoverMastodon } from './lib/platforms/mastodon.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
config({ path: resolve(ROOT, '.env') });
// Consolidate all platform modules into a single object
const PLATFORMS = {
facebook,
linkedin,
threads,
bluesky,
...discoverMastodon(),
};
const LANGS = ['ja', 'ko', 'en'];
function parseArgs(args) {
const opts = {
post: null,
dryRun: false,
only: null,
oldest: false,
latest: false,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--post' && args[i + 1]) opts.post = args[++i];
if (args[i] === '--dry-run') opts.dryRun = true;
if (args[i] === '--only' && args[i + 1]) opts.only = args[++i];
if (args[i] === '--oldest') opts.oldest = true;
if (args[i] === '--latest') opts.latest = true;
}
return opts;
}
// --- sync: RSS feeds → share-status.json synchronization ---
function cmdSync() {
log.info('Syncing RSS feeds → share-status.json...');
const result = readAllFeeds(ROOT);
if (result.error === 'no-feeds') {
log.error('No RSS feeds found in dist/.');
log.plain(' Run `npm run build` first to generate RSS feeds.');
process.exit(1);
}
if (result.missing.length > 0) {
log.warn(`Missing feeds: ${result.missing.join(', ')}`);
}
const { added, total } = syncStatus(ROOT, result.posts);
log.success(`Synced: ${added} new post(s) added (${total} total)`);
}
// --- status: Display share status ---
function cmdStatus(args = []) {
const status = loadStatus(ROOT);
if (status.length === 0) {
log.warn('No posts in share-status.json. Run `npm run share:sync` first.');
return;
}
let limit = 0;
let order = 'latest';
let pendingOnly = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--latest' && args[i + 1]) limit = parseInt(args[++i], 10);
if (args[i] === '--oldest' && args[i + 1]) {
limit = parseInt(args[++i], 10);
order = 'oldest';
}
if (args[i] === '--pending') pendingOnly = true;
}
let entries = status;
if (pendingOnly) {
entries = entries.filter((entry) => {
const langs = Object.keys(entry.versions);
return Object.keys(PLATFORMS).some((platform) => {
const shared = entry.shared[platform] || [];
return langs.some((lang) => !shared.includes(lang));
});
});
}
if (order === 'oldest') entries = [...entries].reverse();
if (limit > 0) entries = entries.slice(0, limit);
log.plain('');
for (const entry of entries) {
const langs = Object.keys(entry.versions);
log.plain(`${entry.permalink} (${entry.date})`);
const platformNames = Object.keys(PLATFORMS);
const maxLen = Math.max(...platformNames.map((n) => n.length));
for (const platform of platformNames) {
const shared = entry.shared[platform] || [];
const marks = langs
.map((lang) => (shared.includes(lang) ? `✓ ${lang}` : `✗ ${lang}`))
.join(' ');
const unsharedCount = langs.filter((l) => !shared.includes(l)).length;
const suffix = unsharedCount > 0 ? ` ← ${unsharedCount} unshared` : '';
const pad = platform.padEnd(maxLen);
log.plain(` ${pad}: ${marks}${suffix}`);
}
log.plain('');
}
}
// --- post: Execute actual sharing ---
async function cmdPost(args) {
const opts = parseArgs(args);
const status = loadStatus(ROOT);
if (status.length === 0) {
log.warn('No posts in share-status.json. Run `npm run share:sync` first.');
return;
}
// Filter only configured platforms (use --only to target a specific platform)
const targetPlatforms = Object.keys(PLATFORMS).filter(
(name) =>
!opts.only || name === opts.only || name.startsWith(`${opts.only}:`)
);
const activePlatforms = {};
for (const name of targetPlatforms) {
if (PLATFORMS[name].isConfigured()) {
activePlatforms[name] = PLATFORMS[name];
} else if (!opts.dryRun) {
log.dim(` SKIP ${name} (credentials not configured)`);
}
}
const configuredPlatforms = opts.dryRun
? undefined
: Object.keys(activePlatforms);
const tasks = getUnshared(status, {
post: opts.post,
only: opts.only,
oldest: opts.oldest,
latest: opts.latest,
platforms: configuredPlatforms,
});
if (tasks.length === 0) {
log.success('All posts are already shared!');
return;
}
let successCount = 0;
let failCount = 0;
for (const task of tasks) {
for (const lang of task.langs) {
const version = task.entry.versions[lang];
if (!version) continue;
const label = `[${task.platform}] ${lang} ${task.entry.permalink}`;
if (opts.dryRun) {
log.info(`DRY RUN: ${label}`);
successCount++;
continue;
}
const mod = activePlatforms[task.platform];
if (!mod) continue;
// Check per-language credentials (Facebook uses language-specific pages)
if (mod.isConfiguredForLang && !mod.isConfiguredForLang(lang)) {
log.dim(` SKIP ${label} (credentials not configured for ${lang})`);
continue;
}
try {
const result = await mod.post(version, task.entry.category, lang);
markShared(ROOT, task.entry.permalink, task.platform, lang);
log.success(`${label}`);
log.dim(` → ${result.url}`);
successCount++;
} catch (err) {
log.error(`${label} → ${err.message}`);
failCount++;
}
}
}
log.plain('');
log.bold(`Results: ${successCount} succeeded, ${failCount} failed`);
}
// --- Main ---
const command = process.argv[2];
const restArgs = process.argv.slice(3);
switch (command) {
case 'sync':
cmdSync();
break;
case 'status':
cmdStatus(restArgs);
break;
case 'post':
await cmdPost(restArgs);
break;
default:
log.plain('Usage:');
log.plain(' npm run share:sync Sync RSS feeds to status file');
log.plain(' npm run share:status Show share status');
log.plain(' npm run share Post unshared items');
process.exit(1);
}
Platform-Specific Message Format — formatter.mjs
This module formats post information into the appropriate message format for each platform. Since each platform has different character limits and link handling methods, they are separated into individual functions.
// scripts/lib/formatter.mjs
export function formatFacebook(version) {
return {
message: `${version.title}\n\n${version.description}`,
link: version.url,
};
}
export function formatLinkedIn(version, category) {
const hashtag = category ? `#${category}` : '';
const commentary = [version.title, '', version.description, '', hashtag]
.filter((line) => line !== undefined)
.join('\n')
.trim();
return {
commentary,
articleUrl: version.url,
};
}
export function formatBluesky(version, category) {
const hashtag = category ? `#${category}` : '';
const parts = [version.title, '', version.description, '', version.url];
if (hashtag) parts.push('', hashtag);
// Bluesky has a 300 grapheme limit
let text = parts.join('\n').trim();
if ([...text].length > 300) {
// Use only title + URL + hashtag without the description
const base = [version.title, '', version.url];
if (hashtag) base.push('', hashtag);
text = base.join('\n').trim();
}
return { text };
}
export function formatMastodon(version, category) {
const hashtag = category ? `#${category}` : '';
const parts = [version.title, '', version.description, '', version.url];
if (hashtag) parts.push('', hashtag);
// Mastodon default limit is 500 characters
return { text: parts.join('\n').trim() };
}
export function formatThreads(version, category) {
const hashtag = category ? `#${category}` : '';
const parts = [version.title, '', version.description, '', version.url];
if (hashtag) parts.push('', hashtag);
return { text: parts.join('\n').trim() };
}
Platform Module Implementation
Each platform module follows the same interface (isConfigured() + post()). When adding a new platform, you only need to implement these two functions.
Facebook operates different pages for each language, so it additionally implements an isConfiguredForLang() function. Each language’s page ID and access token are managed via environment variables in the format FACEBOOK_PAGE_ID_JA, FACEBOOK_PAGE_ACCESS_TOKEN_JA.
// scripts/lib/platforms/facebook.mjs
import { formatFacebook } from '../formatter.mjs';
const GRAPH_API = 'https://graph.facebook.com/v21.0';
const LANGS = ['ja', 'ko', 'en'];
function getCredentials(lang) {
const suffix = lang.toUpperCase();
return {
pageId: process.env[`FACEBOOK_PAGE_ID_${suffix}`],
token: process.env[`FACEBOOK_PAGE_ACCESS_TOKEN_${suffix}`],
};
}
export function isConfigured() {
return LANGS.some((lang) => {
const { pageId, token } = getCredentials(lang);
return !!(pageId && token);
});
}
export function isConfiguredForLang(lang) {
const { pageId, token } = getCredentials(lang);
return !!(pageId && token);
}
export async function post(version, category, lang) {
const { pageId, token } = getCredentials(lang);
if (!pageId || !token) {
throw new Error(`Facebook credentials not configured for lang: ${lang}`);
}
const { message, link } = formatFacebook(version);
const res = await fetch(`${GRAPH_API}/${pageId}/feed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, link, access_token: token }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(
`Facebook API error (${res.status}): ${error.error?.message}`
);
}
const data = await res.json();
return { id: data.id, url: `https://www.facebook.com/${data.id}` };
}
The LinkedIn API shares posts in Article format. The X-Restli-Protocol-Version and LinkedIn-Version headers are required.
// scripts/lib/platforms/linkedin.mjs
import { formatLinkedIn } from '../formatter.mjs';
const API_URL = 'https://api.linkedin.com/rest/posts';
export function isConfigured() {
return !!(
process.env.LINKEDIN_ACCESS_TOKEN && process.env.LINKEDIN_PERSON_URN
);
}
export async function post(version, category) {
const { commentary, articleUrl } = formatLinkedIn(version, category);
const token = process.env.LINKEDIN_ACCESS_TOKEN;
const personUrn = process.env.LINKEDIN_PERSON_URN;
const body = {
author: personUrn,
commentary,
visibility: 'PUBLIC',
distribution: { feedDistribution: 'MAIN_FEED' },
content: {
article: {
source: articleUrl,
title: version.title,
description: version.description,
},
},
lifecycleState: 'PUBLISHED',
};
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202602',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`LinkedIn API error (${res.status}): ${text}`);
}
const postUrn = res.headers.get('x-restli-id');
const shareId = postUrn ? postUrn.replace('urn:li:share:', '') : null;
const url = shareId
? `https://www.linkedin.com/feed/update/urn:li:share:${shareId}/`
: 'https://www.linkedin.com/feed/';
return { id: postUrn || 'posted', url };
}
Threads
The Threads API operates in two steps. First, it creates a media container (/threads), then publishes that container (/threads_publish). After publishing, it queries the permalink to obtain the URL of the shared post.
// scripts/lib/platforms/threads.mjs
import { formatThreads } from '../formatter.mjs';
const API_BASE = 'https://graph.threads.net/v1.0';
export function isConfigured() {
return !!(process.env.THREADS_USER_ID && process.env.THREADS_ACCESS_TOKEN);
}
export async function post(version, category) {
const userId = process.env.THREADS_USER_ID;
const token = process.env.THREADS_ACCESS_TOKEN;
const { text } = formatThreads(version, category);
// Step 1: Create media container
const createRes = await fetch(`${API_BASE}/${userId}/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_type: 'TEXT', text, access_token: token }),
});
if (!createRes.ok) {
const error = await createRes.json().catch(() => ({}));
throw new Error(
`Threads API error (${createRes.status}): ${error.error?.message}`
);
}
const { id: containerId } = await createRes.json();
// Step 2: Publish the container
const publishRes = await fetch(`${API_BASE}/${userId}/threads_publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creation_id: containerId, access_token: token }),
});
if (!publishRes.ok) {
const error = await publishRes.json().catch(() => ({}));
throw new Error(
`Threads publish error (${publishRes.status}): ${error.error?.message}`
);
}
const data = await publishRes.json();
// Step 3: Query permalink
let url = 'https://www.threads.net';
if (data.id) {
try {
const metaRes = await fetch(
`${API_BASE}/${data.id}?fields=permalink&access_token=${token}`
);
if (metaRes.ok) {
const meta = await metaRes.json();
if (meta.permalink) url = meta.permalink;
}
} catch {
// Use default URL on failure
}
}
return { id: data.id || 'posted', url };
}
Bluesky
Bluesky uses the AT Protocol. Unlike other platforms, URLs in text are not automatically linked, so you need to specify links manually using facets, a byte-offset-based rich text system. Additionally, embed is used to attach a link preview (card).
// scripts/lib/platforms/bluesky.mjs
import { formatBluesky } from '../formatter.mjs';
const SERVICE = 'https://bsky.social';
export function isConfigured() {
return !!(process.env.BLUESKY_IDENTIFIER && process.env.BLUESKY_APP_PASSWORD);
}
async function createSession() {
const res = await fetch(`${SERVICE}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: process.env.BLUESKY_IDENTIFIER,
password: process.env.BLUESKY_APP_PASSWORD,
}),
});
if (!res.ok) {
throw new Error(`Bluesky auth failed: ${res.statusText}`);
}
return res.json();
}
// Calculate byte offsets of URLs in text to generate facets
function detectUrlFacets(text) {
const encoder = new TextEncoder();
const facets = [];
const urlRegex = /https?:\/\/[^\s)]+/g;
let match;
while ((match = urlRegex.exec(text)) !== null) {
const beforeBytes = encoder.encode(text.slice(0, match.index)).byteLength;
const urlBytes = encoder.encode(match[0]).byteLength;
facets.push({
index: { byteStart: beforeBytes, byteEnd: beforeBytes + urlBytes },
features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }],
});
}
return facets;
}
export async function post(version, category) {
const session = await createSession();
const { text } = formatBluesky(version, category);
const facets = detectUrlFacets(text);
const record = {
$type: 'app.bsky.feed.post',
text,
facets,
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.external',
external: {
uri: version.url,
title: version.title,
description: version.description || '',
},
},
};
const res = await fetch(`${SERVICE}/xrpc/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record,
}),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(`Bluesky API error (${res.status}): ${error.message}`);
}
const data = await res.json();
const rkey = data.uri?.split('/').pop();
const handle = process.env.BLUESKY_IDENTIFIER;
const url = rkey
? `https://bsky.app/profile/${handle}/post/${rkey}`
: 'https://bsky.app';
return { id: data.uri || 'posted', url };
}
Mastodon
Mastodon uses the discoverPlatforms pattern to support multiple instances simultaneously. It automatically detects MASTODON_{NAME}_INSTANCE and MASTODON_{NAME}_ACCESS_TOKEN pairs from environment variables and dynamically creates platform modules.
// scripts/lib/platforms/mastodon.mjs
import { formatMastodon } from '../formatter.mjs';
export function discoverPlatforms() {
const platforms = {};
for (const key of Object.keys(process.env)) {
const match = key.match(/^MASTODON_(.+)_INSTANCE$/);
if (!match) continue;
const suffix = match[1];
const name = `mastodon:${suffix.toLowerCase()}`;
const instance = process.env[`MASTODON_${suffix}_INSTANCE`]?.replace(
/\/+$/,
''
);
const token = process.env[`MASTODON_${suffix}_ACCESS_TOKEN`];
platforms[name] = {
isConfigured: () => !!(instance && token),
post: async (version, category) =>
postTo(instance, token, version, category),
};
}
return platforms;
}
async function postTo(instance, token, version, category) {
const { text } = formatMastodon(version, category);
const res = await fetch(`${instance}/api/v1/statuses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: text }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(`Mastodon API error (${res.status}): ${error.error}`);
}
const data = await res.json();
return { id: data.id || 'posted', url: data.url || instance };
}
For example, if you set the following in .env:
MASTODON_SOCIAL_INSTANCE=https://mastodon.social
MASTODON_SOCIAL_ACCESS_TOKEN=...
MASTODON_MSTDN_INSTANCE=https://mstdn.jp
MASTODON_MSTDN_ACCESS_TOKEN=...
Two platforms, mastodon:social and mastodon:mstdn, are automatically registered.
Terminal Color Logger — logger.mjs
Finally, here’s a simple logger that adds colors to terminal output.
// scripts/lib/logger.mjs
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
const c = (color, text) => `${colors[color]}${text}${colors.reset}`;
export const log = {
info: (msg) => console.log(c('cyan', msg)),
success: (msg) => console.log(c('green', `✓ ${msg}`)),
warn: (msg) => console.log(c('yellow', `⚠ ${msg}`)),
error: (msg) => console.error(c('red', `✗ ${msg}`)),
dim: (msg) => console.log(c('dim', msg)),
bold: (msg) => console.log(c('bold', msg)),
plain: (msg) => console.log(msg),
};
Environment Variable Configuration
All platform API credentials are managed in the .env file at the project root. This file is included in .gitignore so it is not committed to the repository.
# .env
# Facebook (per-language pages)
FACEBOOK_APP_ID=...
FACEBOOK_APP_SECRET=...
FACEBOOK_PAGE_ID_JA=...
FACEBOOK_PAGE_ACCESS_TOKEN_JA=...
FACEBOOK_PAGE_ID_KO=...
FACEBOOK_PAGE_ACCESS_TOKEN_KO=...
FACEBOOK_PAGE_ID_EN=...
FACEBOOK_PAGE_ACCESS_TOKEN_EN=...
# LinkedIn
LINKEDIN_ACCESS_TOKEN=...
LINKEDIN_PERSON_URN=urn:li:person:...
# Threads
THREADS_USER_ID=...
THREADS_ACCESS_TOKEN=...
# Bluesky
BLUESKY_IDENTIFIER=...
BLUESKY_APP_PASSWORD=...
# Mastodon (per instance)
MASTODON_SOCIAL_INSTANCE=https://mastodon.social
MASTODON_SOCIAL_ACCESS_TOKEN=...
Integration with the Deployment Pipeline
This sharing system is naturally integrated into the deployment pipeline. When you run npm run deploy, the predeploy hook automatically runs share:sync first, registering new posts in share-status.json.
{
"predeploy": "npm run share:sync"
}
Here’s a summary of the overall flow:
npm run deploy
→ predeploy: npm run share:sync
→ preshare:sync: npm run build
→ build: astro build && pagefind
→ share:sync: node scripts/share.mjs sync
→ deploy: gh-pages -d dist
After deployment is complete, you run npm run share separately to share new posts to social media. The reason for separating deployment and sharing is that it’s safer to share only after confirming the deployment was successful.
Wrap-up
In this post, we shared the complete code for the blog post social share automation system.
rss-reader.mjs: Extracts language-specific post information from built RSS feedsstatus-manager.mjs: Tracks per-post/per-platform share status withshare-status.jsonshare.mjs: CLI handling three commands: sync, status, and postformatter.mjs: Message formatting that respects each platform’s character limits- 5 platform modules: Facebook (per-language pages), LinkedIn, Threads (two-step publishing), Bluesky (AT Protocol facets), Mastodon (multi-instance)
- Natural integration with the deployment pipeline through the
predeployhook
In the next post Troubleshooting and Tips, we’ll share the problems encountered during migration and their solutions.
Series Guide
This post is part of the Jekyll to Astro migration series.
- Why I Migrated from Jekyll to Astro
- Astro Installation and Project Setup
- Content Collections and Markdown Migration
- Multilingual (i18n) Implementation
- SEO Implementation
- Image Optimization — Custom rehype Plugin
- Comment System (Utterances)
- Ad Integration (Google AdSense)
- Search Implementation with Pagefind
- Layout and Component Architecture
- GitHub Pages Deployment
- Social Share Automation Script
- Troubleshooting and Tips
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.