feat: add source capability detection, protocol indicators, and reply routing

- Add lib/feeds/capabilities.js: detect feed source capabilities
  (webmention, micropub, platform type) on subscribe and first fetch
- Enrich timeline items with source_type from capabilities or URL inference
- Add protocol indicator icons (Bluesky/Mastodon/web) to item-card.njk
- Auto-select syndication target in compose based on interaction URL protocol
- Modified: follow.js, processor.js, reader.js, item-card.njk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-18 08:47:50 +01:00
parent 1f4a6876ec
commit 114998bf03
6 changed files with 302 additions and 6 deletions

View File

@@ -5,6 +5,7 @@
import { IndiekitError } from "@indiekit/error";
import { detectCapabilities } from "../feeds/capabilities.js";
import { refreshFeedNow } from "../polling/scheduler.js";
import { getChannel } from "../storage/channels.js";
import {
@@ -12,6 +13,7 @@ import {
deleteFeed,
getFeedByUrl,
getFeedsForChannel,
updateFeed,
} from "../storage/feeds.js";
import { getUserId } from "../utils/auth.js";
import { notifyBlogroll } from "../utils/blogroll-notify.js";
@@ -79,6 +81,15 @@ export async function follow(request, response) {
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
});
// Detect source capabilities (fire-and-forget)
detectCapabilities(url).then((capabilities) => {
updateFeed(application, feed._id, { capabilities }).catch((error) => {
console.error(`[Microsub] Capability storage error:`, error.message);
});
}).catch((error) => {
console.error(`[Microsub] Capability detection error for ${url}:`, error.message);
});
// Notify blogroll plugin (fire-and-forget)
notifyBlogroll(application, "follow", {
url,

View File

@@ -347,6 +347,20 @@ function ensureString(value) {
return String(value);
}
/**
* Detect the protocol of a URL for auto-syndication targeting
* @param {string} url - URL to classify
* @returns {string} "atmosphere" | "fediverse" | "web"
*/
function detectProtocol(url) {
if (!url || typeof url !== "string") return "web";
const lower = url.toLowerCase();
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
return "web";
}
/**
* Fetch syndication targets from Micropub config
* @param {object} application - Indiekit application
@@ -406,6 +420,20 @@ export async function compose(request, response) {
? await getSyndicationTargets(application, token)
: [];
// Auto-select syndication target based on interaction URL protocol
const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
if (interactionUrl && syndicationTargets.length > 0) {
const protocol = detectProtocol(interactionUrl);
for (const target of syndicationTargets) {
const targetId = (target.uid || target.name || "").toLowerCase();
if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
target.checked = true;
} else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
target.checked = true;
}
}
}
response.render("compose", {
title: request.__("microsub.compose.title"),
replyTo: ensureString(replyTo || reply),

204
lib/feeds/capabilities.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Source capability detection
* Detects what a feed source supports (webmention, micropub, platform API)
* @module feeds/capabilities
*/
/**
* Known Fediverse domain patterns
*/
const FEDIVERSE_PATTERNS = [
"mastodon.",
"mstdn.",
"fosstodon.",
"pleroma.",
"misskey.",
"pixelfed.",
"fediverse",
];
/**
* Detect the capabilities of a feed source
* @param {string} feedUrl - The feed URL
* @param {string} [siteUrl] - Optional site homepage URL (if different from feed)
* @returns {Promise<object>} Capability profile
*/
export async function detectCapabilities(feedUrl, siteUrl) {
const result = {
source_type: "publication",
webmention: null,
micropub: null,
platform_api: null,
author_mode: "single",
interactions: [],
detected_at: new Date().toISOString(),
};
try {
// 1. Pattern-match feed URL for known platforms
const platformMatch = matchPlatform(feedUrl);
if (platformMatch) {
result.source_type = platformMatch.type;
result.platform_api = platformMatch.api;
result.interactions = platformMatch.interactions;
return result;
}
// 2. Fetch site homepage and check for rel links
const homepageUrl = siteUrl || deriveHomepage(feedUrl);
if (homepageUrl) {
const endpoints = await discoverEndpoints(homepageUrl);
result.webmention = endpoints.webmention;
result.micropub = endpoints.micropub;
if (endpoints.webmention && endpoints.micropub) {
result.source_type = "indieweb";
result.interactions = ["reply", "like", "repost", "bookmark"];
} else if (endpoints.webmention) {
result.source_type = "web";
result.interactions = ["reply"];
}
}
} catch (error) {
console.error(
`[Microsub] Capability detection failed for ${feedUrl}:`,
error.message,
);
}
return result;
}
/**
* Pattern-match a feed URL against known platforms
* @param {string} url - Feed URL
* @returns {object|null} Platform match or null
*/
function matchPlatform(url) {
const lower = url.toLowerCase();
// Bluesky
if (lower.includes("bsky.app") || lower.includes("bluesky")) {
return {
type: "bluesky",
api: { type: "atproto", authed: false },
interactions: ["reply", "like", "repost"],
};
}
// Mastodon / Fediverse RSS (e.g., mastodon.social/@user.rss)
if (FEDIVERSE_PATTERNS.some((pattern) => lower.includes(pattern))) {
return {
type: "mastodon",
api: { type: "activitypub", authed: false },
interactions: ["reply", "like", "repost"],
};
}
// WordPress (common RSS patterns)
if (lower.includes("/wp-json/") || lower.includes("/feed/")) {
// Could be WordPress but also others — don't match too broadly
// Only match the /wp-json/ pattern which is WordPress-specific
if (lower.includes("/wp-json/")) {
return {
type: "wordpress",
api: { type: "wp-rest", authed: false },
interactions: ["reply"],
};
}
}
return null;
}
/**
* Derive a homepage URL from a feed URL
* @param {string} feedUrl - Feed URL
* @returns {string|null} Homepage URL
*/
function deriveHomepage(feedUrl) {
try {
const url = new URL(feedUrl);
return `${url.protocol}//${url.host}/`;
} catch {
return null;
}
}
/**
* Discover webmention and micropub endpoints from a URL
* @param {string} url - URL to check for endpoint links
* @returns {Promise<object>} Discovered endpoints
*/
async function discoverEndpoints(url) {
const endpoints = {
webmention: null,
micropub: null,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "text/html",
"User-Agent": "Microsub/1.0 (+https://indieweb.org/Microsub)",
},
redirect: "follow",
});
if (!response.ok) return endpoints;
// Check Link headers first
const linkHeader = response.headers.get("link");
if (linkHeader) {
const wmMatch = linkHeader.match(
/<([^>]+)>;\s*rel="?webmention"?/i,
);
if (wmMatch) endpoints.webmention = wmMatch[1];
const mpMatch = linkHeader.match(
/<([^>]+)>;\s*rel="?micropub"?/i,
);
if (mpMatch) endpoints.micropub = mpMatch[1];
}
// If not found in headers, check HTML
if (!endpoints.webmention || !endpoints.micropub) {
const html = await response.text();
if (!endpoints.webmention) {
const wmHtml = html.match(
/<link[^>]+rel="?webmention"?[^>]+href="([^"]+)"/i,
) ||
html.match(
/<link[^>]+href="([^"]+)"[^>]+rel="?webmention"?/i,
);
if (wmHtml) endpoints.webmention = wmHtml[1];
}
if (!endpoints.micropub) {
const mpHtml = html.match(
/<link[^>]+rel="?micropub"?[^>]+href="([^"]+)"/i,
) ||
html.match(
/<link[^>]+href="([^"]+)"[^>]+rel="?micropub"?/i,
);
if (mpHtml) endpoints.micropub = mpHtml[1];
}
}
} catch (error) {
if (error.name !== "AbortError") {
console.debug(
`[Microsub] Endpoint discovery failed for ${url}:`,
error.message,
);
}
} finally {
clearTimeout(timeout);
}
return endpoints;
}

View File

@@ -4,9 +4,11 @@
*/
import { getRedisClient, publishEvent } from "../cache/redis.js";
import { detectCapabilities } from "../feeds/capabilities.js";
import { fetchAndParseFeed } from "../feeds/fetcher.js";
import { getChannel } from "../storage/channels.js";
import {
updateFeed,
updateFeedAfterFetch,
updateFeedStatus,
updateFeedWebsub,
@@ -82,6 +84,15 @@ export async function processFeed(application, feed) {
item._source.name = feed.title || parsed.name;
}
// Attach source_type from feed capabilities (for protocol indicators)
// Falls back to URL-based inference when capabilities haven't been detected yet
item._source = item._source || {};
if (feed.capabilities?.source_type) {
item._source.source_type = feed.capabilities.source_type;
} else {
item._source.source_type = inferSourceType(feed.url);
}
// Store the item
const stored = await addItem(application, {
channelId: feed.channelId,
@@ -177,6 +188,20 @@ export async function processFeed(application, feed) {
success: true,
itemCount: parsed.items?.length || 0,
});
// Detect source capabilities on first successful fetch (if not yet detected)
if (!feed.capabilities) {
detectCapabilities(feed.url)
.then((capabilities) => {
updateFeed(application, feed._id, { capabilities }).catch(() => {});
})
.catch((error) => {
console.debug(
`[Microsub] Capability detection skipped for ${feed.url}:`,
error.message,
);
});
}
} catch (error) {
result.error = error.message;
@@ -208,6 +233,21 @@ export async function processFeed(application, feed) {
return result;
}
/**
* Infer source type from feed URL when capabilities haven't been detected yet
* @param {string} url - Feed URL
* @returns {string} Source type
*/
function inferSourceType(url) {
if (!url) return "web";
const lower = url.toLowerCase();
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "bluesky";
if (lower.includes("mastodon.") || lower.includes("mstdn.") ||
lower.includes("fosstodon.") || lower.includes("pleroma.") ||
lower.includes("misskey.") || lower.includes("pixelfed.")) return "mastodon";
return "web";
}
/**
* Check if an item passes channel filters
* @param {object} item - Feed item