mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
204
lib/feeds/capabilities.js
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||
"version": "1.0.32",
|
||||
"version": "1.0.33",
|
||||
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
@@ -63,13 +63,26 @@
|
||||
{% endif %}
|
||||
<div class="item-card__author-info">
|
||||
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
||||
{% if item._source and item._source.type === "activitypub" %}
|
||||
{% if item._source %}
|
||||
<span class="item-card__source">
|
||||
<span class="item-card__badge item-card__badge--ap" title="Fediverse">AP</span>
|
||||
{{ item.author.url | replace("https://", "") | replace("http://", "") }}
|
||||
{# Protocol source indicator #}
|
||||
{% set sourceUrl = item._source.url or item.author.url or "" %}
|
||||
{% set sourceType = item._source.source_type or item._source.type %}
|
||||
{% if sourceType == "activitypub" or sourceType == "mastodon" or ("mastodon." in sourceUrl) or ("mstdn." in sourceUrl) or ("fosstodon." in sourceUrl) or ("pleroma." in sourceUrl) or ("misskey." in sourceUrl) %}
|
||||
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="#6364ff" aria-label="Fediverse" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
||||
</svg>
|
||||
{% elif sourceType == "bluesky" or ("bsky.app" in sourceUrl) or ("bluesky" in sourceUrl) %}
|
||||
<svg class="item-card__source-icon" viewBox="0 0 568 501" fill="#0085ff" aria-label="ATmosphere" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
||||
<path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="item-card__source-icon" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" aria-label="Web" style="width:12px;height:12px;vertical-align:middle;margin-right:3px;display:inline-block">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ item._source.name or item._source.url }}
|
||||
</span>
|
||||
{% elif item._source %}
|
||||
<span class="item-card__source">{{ item._source.name or item._source.url }}</span>
|
||||
{% elif item.author.url %}
|
||||
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user