From afc394525bfc607120ff12d49a6f907ad505abde Mon Sep 17 00:00:00 2001 From: rmdes Date: Wed, 18 Feb 2026 08:42:59 +0100 Subject: [PATCH] feat: add textcasting support, feed attachments, and protocol badges - Add _textcasting extension to JSON feed with support/monetization config - Add feedAttachments filter for photo/audio/video media in feed items - Add content_text and date_modified fields to feed items - Add protocol badges (ATmosphere, Fediverse, Web) on reply posts - Add support configuration via environment variables in site data Co-Authored-By: Claude Opus 4.6 --- _data/site.js | 8 ++++++ eleventy.config.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++ feed-json.njk | 18 ++++++++++-- replies.njk | 27 +++++++++++++++-- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/_data/site.js b/_data/site.js index d004497..32776a9 100644 --- a/_data/site.js +++ b/_data/site.js @@ -101,4 +101,12 @@ export default { // Fediverse creator for meta tag (e.g., @rmdes@mstdn.social) fediverseCreator: getMastodonHandle(), + + // Support/monetization configuration (used in _textcasting JSON Feed extension) + support: { + url: process.env.SUPPORT_URL || null, + stripe: process.env.SUPPORT_STRIPE_URL || null, + lightning: process.env.SUPPORT_LIGHTNING_ADDRESS || null, + paymentPointer: process.env.SUPPORT_PAYMENT_POINTER || null, + }, }; diff --git a/eleventy.config.js b/eleventy.config.js index 547fdad..36bb6af 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -69,6 +69,78 @@ export default function (eleventyConfig) { return JSON.stringify(value); }); + // Guess MIME type from URL extension + function guessMimeType(url, category) { + const lower = (typeof url === "string" ? url : "").toLowerCase(); + if (category === "photo") { + if (lower.includes(".png")) return "image/png"; + if (lower.includes(".gif")) return "image/gif"; + if (lower.includes(".webp")) return "image/webp"; + if (lower.includes(".svg")) return "image/svg+xml"; + return "image/jpeg"; + } + if (category === "audio") { + if (lower.includes(".ogg") || lower.includes(".opus")) return "audio/ogg"; + if (lower.includes(".flac")) return "audio/flac"; + if (lower.includes(".wav")) return "audio/wav"; + return "audio/mpeg"; + } + if (category === "video") { + if (lower.includes(".webm")) return "video/webm"; + if (lower.includes(".mov")) return "video/quicktime"; + return "video/mp4"; + } + return "application/octet-stream"; + } + + // Extract URL string from value that may be a string or {url, alt} object + function resolveMediaUrl(value) { + if (typeof value === "string") return value; + if (value && typeof value === "object" && value.url) return value.url; + return null; + } + + // Feed attachments filter — builds JSON Feed attachments array from post data + eleventyConfig.addFilter("feedAttachments", (postData) => { + const attachments = []; + const processMedia = (items, category) => { + const list = Array.isArray(items) ? items : [items]; + for (const item of list) { + const rawUrl = resolveMediaUrl(item); + if (!rawUrl) continue; + const url = rawUrl.startsWith("http") ? rawUrl : `${siteUrl}${rawUrl}`; + attachments.push({ url, mime_type: guessMimeType(rawUrl, category) }); + } + }; + if (postData.photo) processMedia(postData.photo, "photo"); + if (postData.audio) processMedia(postData.audio, "audio"); + if (postData.video) processMedia(postData.video, "video"); + return attachments; + }); + + // Textcasting support filter — builds clean support object excluding null values + eleventyConfig.addFilter("textcastingSupport", (support) => { + if (!support) return {}; + const obj = {}; + if (support.url) obj.url = support.url; + if (support.stripe) obj.stripe = support.stripe; + if (support.lightning) obj.lightning = support.lightning; + if (support.paymentPointer) obj.payment_pointer = support.paymentPointer; + return obj; + }); + + // Protocol type filter — classifies a URL by its origin protocol/network + eleventyConfig.addFilter("protocolType", (url) => { + if (!url || typeof url !== "string") return "web"; + const lower = url.toLowerCase(); + if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere"; + // Match Fediverse instances by known domain patterns (avoid overly broad "social") + if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") || + lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.") || + lower.includes("fediverse")) return "fediverse"; + return "web"; + }); + // Email obfuscation filter - converts email to HTML entities // Blocks ~95% of spam harvesters while remaining valid for microformat parsers // Usage: {{ email | obfuscateEmail }} or {{ email | obfuscateEmail("href") }} diff --git a/feed-json.njk b/feed-json.njk index d45ed46..fd1f656 100644 --- a/feed-json.njk +++ b/feed-json.njk @@ -21,6 +21,14 @@ eleventyExcludeFromCollections: true "url": "{{ site.url }}/" } ], + "_textcasting": { + "version": "1.0", + "about": "https://textcasting.org/" + {%- set hasSupport = site.support and (site.support.url or site.support.stripe or site.support.lightning or site.support.paymentPointer) %} + {%- if hasSupport %}, + "support": {{ site.support | textcastingSupport | jsonEncode | safe }} + {%- endif %} + }, "items": [ {%- for post in collections.feed %} {%- set absolutePostUrl = site.url + post.url %} @@ -37,12 +45,18 @@ eleventyExcludeFromCollections: true { "id": "{{ absolutePostUrl }}", "url": "{{ absolutePostUrl }}", - "title": {{ post.data.title | default(post.content | striptags | truncate(80)) | jsonEncode | safe }}, + "title": {% if post.data.title %}{{ post.data.title | jsonEncode | safe }}{% else %}null{% endif %}, "content_html": {{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | jsonEncode | safe }}, - "date_published": "{{ post.date | dateToRfc3339 }}" + "content_text": {{ post.content | striptags | jsonEncode | safe }}, + "date_published": "{{ post.date | dateToRfc3339 }}", + "date_modified": "{{ (post.data.updated or post.date) | dateToRfc3339 }}" {%- if postImage and postImage != "" and (postImage | length) > 10 %}, "image": "{{ postImage | url | absoluteUrl(site.url) }}" {%- endif %} + {%- set attachments = post.data | feedAttachments %} + {%- if attachments.length > 0 %}, + "attachments": {{ attachments | jsonEncode | safe }} + {%- endif %} }{% if not loop.last %},{% endif %} {%- endfor %} ] diff --git a/replies.njk b/replies.njk index 8eab057..696e1bb 100644 --- a/replies.njk +++ b/replies.njk @@ -51,11 +51,34 @@ permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum {# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #} {% set replyTo = post.data.inReplyTo or post.data.in_reply_to %} {% if replyTo %} -

+ {% set protocol = replyTo | protocolType %} +

In reply to: - {{ replyTo }} + {{ replyTo | replace("https://", "") | replace("http://", "") | truncate(60) }} + {% if protocol == "atmosphere" %} + + + via ATmosphere + + {% elif protocol == "fediverse" %} + + + via Fediverse + + {% else %} + + + via Web + + {% endif %}

{% endif %}