@rmdes/indiekit-endpoint-microsub 1.0.21 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/styles.css CHANGED
@@ -763,3 +763,176 @@
763
763
  width: 100%;
764
764
  }
765
765
  }
766
+
767
+ /* ==========================================================================
768
+ Badge extensions for search results
769
+ ========================================================================== */
770
+
771
+ /* Extend Indiekit badges with small variant for inline use */
772
+ .badge--small {
773
+ font-size: var(--font-size-small);
774
+ padding: 2px var(--space-xs);
775
+ }
776
+
777
+ /* ==========================================================================
778
+ Search Enhancements (feed validation)
779
+ ========================================================================== */
780
+
781
+ .search__name {
782
+ display: block;
783
+ font-weight: 600;
784
+ margin-bottom: var(--space-xs);
785
+ }
786
+
787
+ .search__type {
788
+ margin-left: var(--space-xs);
789
+ }
790
+
791
+ .search__error {
792
+ color: var(--color-error, #ff4444);
793
+ display: block;
794
+ font-size: var(--font-size-small);
795
+ margin-top: var(--space-xs);
796
+ }
797
+
798
+ .search__item--invalid {
799
+ opacity: 0.7;
800
+ }
801
+
802
+ .search__item--comments {
803
+ border-left: 3px solid var(--color-warning, #ffcc00);
804
+ }
805
+
806
+
807
+ .search__subscribe {
808
+ align-items: center;
809
+ display: flex;
810
+ gap: var(--space-s);
811
+ }
812
+
813
+ /* ==========================================================================
814
+ Notices (inline errors, warnings)
815
+ ========================================================================== */
816
+
817
+ .notice {
818
+ border-radius: var(--border-radius-small, var(--border-radius));
819
+ margin-bottom: var(--space-m);
820
+ padding: var(--space-m);
821
+ }
822
+
823
+ .notice--error {
824
+ background: var(--color-red90, #fef2f2);
825
+ border: 1px solid var(--color-error, var(--color-red45));
826
+ color: var(--color-red10, #7f1d1d);
827
+ }
828
+
829
+ .notice--warning {
830
+ background: var(--color-yellow90, #fefce8);
831
+ border: 1px solid var(--color-yellow50, #eab308);
832
+ color: var(--color-yellow10, #713f12);
833
+ }
834
+
835
+ .notice--success {
836
+ background: var(--color-green90, #f0fdf4);
837
+ border: 1px solid var(--color-success, var(--color-green50));
838
+ color: var(--color-green10, #14532d);
839
+ }
840
+
841
+ /* ==========================================================================
842
+ Feed Management Enhancements
843
+ ========================================================================== */
844
+
845
+ .feeds__item--error {
846
+ border-left: 3px solid var(--color-error, #ff4444);
847
+ }
848
+
849
+ .feeds__error {
850
+ color: var(--color-error, #ff4444);
851
+ display: block;
852
+ font-size: var(--font-size-small);
853
+ margin-top: var(--space-xs);
854
+ }
855
+
856
+ .feeds__error-count {
857
+ color: var(--color-warning, #ffcc00);
858
+ display: block;
859
+ font-size: var(--font-size-small);
860
+ }
861
+
862
+ .feeds__meta {
863
+ color: var(--color-text-muted);
864
+ display: block;
865
+ font-size: var(--font-size-small);
866
+ }
867
+
868
+ .feeds__details {
869
+ display: flex;
870
+ flex-direction: column;
871
+ flex: 1;
872
+ min-width: 0;
873
+ }
874
+
875
+ .feeds__actions {
876
+ align-items: center;
877
+ display: flex;
878
+ flex-shrink: 0;
879
+ gap: var(--space-xs);
880
+ }
881
+
882
+ .feeds__actions form {
883
+ display: inline;
884
+ margin: 0;
885
+ }
886
+
887
+
888
+ /* ==========================================================================
889
+ Feed Edit Page
890
+ ========================================================================== */
891
+
892
+ .feed-edit {
893
+ max-width: 40rem;
894
+ }
895
+
896
+ .feed-edit__current {
897
+ background: var(--color-offset);
898
+ border-radius: var(--border-radius);
899
+ margin-bottom: var(--space-l);
900
+ padding: var(--space-m);
901
+ }
902
+
903
+ .feed-edit__url {
904
+ color: var(--color-text-muted);
905
+ font-size: var(--font-size-small);
906
+ overflow-wrap: break-word;
907
+ word-break: break-all;
908
+ }
909
+
910
+ .feed-edit__title {
911
+ font-weight: 600;
912
+ }
913
+
914
+ .feed-edit__form {
915
+ margin-bottom: var(--space-l);
916
+ }
917
+
918
+ .feed-edit__help {
919
+ color: var(--color-text-muted);
920
+ font-size: var(--font-size-small);
921
+ margin-bottom: var(--space-m);
922
+ }
923
+
924
+ .feed-edit__actions {
925
+ display: flex;
926
+ flex-direction: column;
927
+ gap: var(--space-m);
928
+ }
929
+
930
+ .feed-edit__action {
931
+ background: var(--color-offset);
932
+ border-radius: var(--border-radius);
933
+ padding: var(--space-m);
934
+ }
935
+
936
+ .feed-edit__action p {
937
+ margin-bottom: var(--space-s);
938
+ }
package/index.js CHANGED
@@ -3,10 +3,11 @@ import path from "node:path";
3
3
  import express from "express";
4
4
 
5
5
  import { microsubController } from "./lib/controllers/microsub.js";
6
+ import { opmlController } from "./lib/controllers/opml.js";
6
7
  import { readerController } from "./lib/controllers/reader.js";
7
8
  import { handleMediaProxy } from "./lib/media/proxy.js";
8
9
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
9
- import { createIndexes } from "./lib/storage/items.js";
10
+ import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
10
11
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
11
12
  import { websubHandler } from "./lib/websub/handler.js";
12
13
 
@@ -90,6 +91,26 @@ export default class MicrosubEndpoint {
90
91
  "/channels/:uid/feeds/remove",
91
92
  readerController.removeFeed,
92
93
  );
94
+ readerRouter.get(
95
+ "/channels/:uid/feeds/:feedId",
96
+ readerController.feedDetails,
97
+ );
98
+ readerRouter.get(
99
+ "/channels/:uid/feeds/:feedId/edit",
100
+ readerController.editFeedForm,
101
+ );
102
+ readerRouter.post(
103
+ "/channels/:uid/feeds/:feedId/edit",
104
+ readerController.updateFeedUrl,
105
+ );
106
+ readerRouter.post(
107
+ "/channels/:uid/feeds/:feedId/rediscover",
108
+ readerController.rediscoverFeed,
109
+ );
110
+ readerRouter.post(
111
+ "/channels/:uid/feeds/:feedId/refresh",
112
+ readerController.refreshFeed,
113
+ );
93
114
  readerRouter.get("/item/:id", readerController.item);
94
115
  readerRouter.get("/compose", readerController.compose);
95
116
  readerRouter.post("/compose", readerController.submitCompose);
@@ -97,6 +118,7 @@ export default class MicrosubEndpoint {
97
118
  readerRouter.post("/search", readerController.searchFeeds);
98
119
  readerRouter.post("/subscribe", readerController.subscribe);
99
120
  readerRouter.post("/api/mark-read", readerController.markAllRead);
121
+ readerRouter.get("/opml", opmlController.exportOpml);
100
122
  router.use("/reader", readerRouter);
101
123
 
102
124
  return router;
@@ -157,6 +179,11 @@ export default class MicrosubEndpoint {
157
179
  createIndexes(indiekit).catch((error) => {
158
180
  console.warn("[Microsub] Index creation failed:", error.message);
159
181
  });
182
+
183
+ // Cleanup old read items on startup
184
+ cleanupAllReadItems(indiekit).catch((error) => {
185
+ console.warn("[Microsub] Startup cleanup failed:", error.message);
186
+ });
160
187
  } else {
161
188
  console.warn(
162
189
  "[Microsub] Database not available at init, scheduler not started",
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OPML export controller
3
+ * @module controllers/opml
4
+ */
5
+
6
+ import { getChannels } from "../storage/channels.js";
7
+ import { getFeedsForChannel } from "../storage/feeds.js";
8
+ import { getUserId } from "../utils/auth.js";
9
+
10
+ /**
11
+ * Generate OPML export of all subscriptions
12
+ * GET /opml
13
+ * @param {object} request - Express request
14
+ * @param {object} response - Express response
15
+ * @returns {Promise<void>}
16
+ */
17
+ async function exportOpml(request, response) {
18
+ const { application } = request.app.locals;
19
+ const userId = getUserId(request);
20
+
21
+ const channels = await getChannels(application, userId);
22
+
23
+ // Build OPML structure
24
+ const outlines = [];
25
+
26
+ for (const channel of channels) {
27
+ const feeds = await getFeedsForChannel(application, channel._id);
28
+
29
+ if (feeds.length === 0) continue;
30
+
31
+ const channelOutlines = feeds.map((feed) => ({
32
+ text: feed.title || extractDomain(feed.url),
33
+ title: feed.title || "",
34
+ type: "rss",
35
+ xmlUrl: feed.url,
36
+ htmlUrl: deriveSiteUrl(feed.url),
37
+ }));
38
+
39
+ outlines.push({
40
+ text: channel.name,
41
+ title: channel.name,
42
+ children: channelOutlines,
43
+ });
44
+ }
45
+
46
+ const siteUrl = application.publication?.me || "https://example.com";
47
+ const siteName = extractDomain(siteUrl);
48
+
49
+ const opml = generateOpmlXml({
50
+ title: `${siteName} - Microsub Subscriptions`,
51
+ dateCreated: new Date().toUTCString(),
52
+ ownerName: userId,
53
+ outlines,
54
+ });
55
+
56
+ response.set("Content-Type", "text/x-opml");
57
+ response.set(
58
+ "Content-Disposition",
59
+ 'attachment; filename="subscriptions.opml"',
60
+ );
61
+ response.send(opml);
62
+ }
63
+
64
+ /**
65
+ * Generate OPML XML from data
66
+ * @param {object} data - OPML data
67
+ * @param {string} data.title - Document title
68
+ * @param {string} data.dateCreated - Creation date
69
+ * @param {string} data.ownerName - Owner name
70
+ * @param {Array} data.outlines - Outline items
71
+ * @returns {string} OPML XML string
72
+ */
73
+ function generateOpmlXml({ title, dateCreated, ownerName, outlines }) {
74
+ const renderOutline = (outline, indent = " ") => {
75
+ if (outline.children) {
76
+ const childrenXml = outline.children
77
+ .map((child) => renderOutline(child, indent + " "))
78
+ .join("\n");
79
+ return `${indent}<outline text="${escapeXml(outline.text)}" title="${escapeXml(outline.title)}">\n${childrenXml}\n${indent}</outline>`;
80
+ }
81
+ return `${indent}<outline text="${escapeXml(outline.text)}" title="${escapeXml(outline.title)}" type="${outline.type}" xmlUrl="${escapeXml(outline.xmlUrl)}" htmlUrl="${escapeXml(outline.htmlUrl)}"/>`;
82
+ };
83
+
84
+ const outlinesXml = outlines.map((o) => renderOutline(o)).join("\n");
85
+
86
+ return `<?xml version="1.0" encoding="UTF-8"?>
87
+ <opml version="2.0">
88
+ <head>
89
+ <title>${escapeXml(title)}</title>
90
+ <dateCreated>${dateCreated}</dateCreated>
91
+ <ownerName>${escapeXml(ownerName)}</ownerName>
92
+ </head>
93
+ <body>
94
+ ${outlinesXml}
95
+ </body>
96
+ </opml>`;
97
+ }
98
+
99
+ /**
100
+ * Escape XML special characters
101
+ * @param {string} str - String to escape
102
+ * @returns {string} Escaped string
103
+ */
104
+ function escapeXml(str) {
105
+ if (!str) return "";
106
+ return String(str)
107
+ .replace(/&/g, "&amp;")
108
+ .replace(/</g, "&lt;")
109
+ .replace(/>/g, "&gt;")
110
+ .replace(/"/g, "&quot;")
111
+ .replace(/'/g, "&apos;");
112
+ }
113
+
114
+ /**
115
+ * Extract domain from URL
116
+ * @param {string} url - URL to extract domain from
117
+ * @returns {string} Domain
118
+ */
119
+ function extractDomain(url) {
120
+ try {
121
+ return new URL(url).hostname;
122
+ } catch {
123
+ return url;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Derive site URL from feed URL
129
+ * @param {string} feedUrl - Feed URL
130
+ * @returns {string} Site URL
131
+ */
132
+ function deriveSiteUrl(feedUrl) {
133
+ try {
134
+ const url = new URL(feedUrl);
135
+ // Remove common feed paths
136
+ const path = url.pathname
137
+ .replace(/\/feed\/?$/, "")
138
+ .replace(/\/rss\/?$/, "")
139
+ .replace(/\/atom\.xml$/, "")
140
+ .replace(/\/rss\.xml$/, "")
141
+ .replace(/\/feed\.xml$/, "")
142
+ .replace(/\/index\.xml$/, "")
143
+ .replace(/\.rss$/, "")
144
+ .replace(/\.atom$/, "");
145
+ return `${url.origin}${path || "/"}`;
146
+ } catch {
147
+ return feedUrl;
148
+ }
149
+ }
150
+
151
+ export const opmlController = { exportOpml };
@@ -3,7 +3,9 @@
3
3
  * @module controllers/reader
4
4
  */
5
5
 
6
- import { discoverFeedsFromUrl } from "../feeds/fetcher.js";
6
+ import { discoverAndValidateFeeds, getBestFeed } from "../feeds/discovery.js";
7
+ import { validateFeedUrl } from "../feeds/validator.js";
8
+ import { ObjectId } from "mongodb";
7
9
  import { refreshFeedNow } from "../polling/scheduler.js";
8
10
  import {
9
11
  getChannels,
@@ -14,8 +16,11 @@ import {
14
16
  } from "../storage/channels.js";
15
17
  import {
16
18
  getFeedsForChannel,
19
+ getFeedById,
17
20
  createFeed,
18
21
  deleteFeed,
22
+ updateFeed,
23
+ updateFeedStatus,
19
24
  } from "../storage/feeds.js";
20
25
  import {
21
26
  getTimelineItems,
@@ -585,7 +590,7 @@ export async function searchPage(request, response) {
585
590
  }
586
591
 
587
592
  /**
588
- * Search for feeds from URL
593
+ * Search for feeds from URL - enhanced with validation
589
594
  * @param {object} request - Express request
590
595
  * @param {object} response - Express response
591
596
  * @returns {Promise<void>}
@@ -598,11 +603,14 @@ export async function searchFeeds(request, response) {
598
603
  const channelList = await getChannels(application, userId);
599
604
 
600
605
  let results = [];
606
+ let discoveryError = null;
607
+
601
608
  if (query) {
602
609
  try {
603
- results = await discoverFeedsFromUrl(query);
604
- } catch {
605
- // Ignore discovery errors
610
+ // Use enhanced discovery with validation
611
+ results = await discoverAndValidateFeeds(query);
612
+ } catch (error) {
613
+ discoveryError = error.message;
606
614
  }
607
615
  }
608
616
 
@@ -611,13 +619,14 @@ export async function searchFeeds(request, response) {
611
619
  channels: channelList,
612
620
  query,
613
621
  results,
622
+ discoveryError,
614
623
  searched: true,
615
624
  baseUrl: request.baseUrl,
616
625
  });
617
626
  }
618
627
 
619
628
  /**
620
- * Subscribe to a feed from search results
629
+ * Subscribe to a feed from search results - with validation
621
630
  * @param {object} request - Express request
622
631
  * @param {object} response - Express response
623
632
  * @returns {Promise<void>}
@@ -625,13 +634,34 @@ export async function searchFeeds(request, response) {
625
634
  export async function subscribe(request, response) {
626
635
  const { application } = request.app.locals;
627
636
  const userId = getUserId(request);
628
- const { url, channel: channelUid } = request.body;
637
+ const { url, channel: channelUid, skipValidation } = request.body;
629
638
 
630
639
  const channelDocument = await getChannel(application, channelUid, userId);
631
640
  if (!channelDocument) {
632
641
  return response.status(404).render("404");
633
642
  }
634
643
 
644
+ // Validate feed unless explicitly skipped (for power users)
645
+ if (!skipValidation) {
646
+ const validation = await validateFeedUrl(url);
647
+
648
+ if (!validation.valid) {
649
+ const channelList = await getChannels(application, userId);
650
+ return response.render("search", {
651
+ title: request.__("microsub.search.title"),
652
+ channels: channelList,
653
+ query: url,
654
+ validationError: validation.error,
655
+ baseUrl: request.baseUrl,
656
+ });
657
+ }
658
+
659
+ // Warn about comments feeds but allow subscription
660
+ if (validation.isCommentsFeed) {
661
+ console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
662
+ }
663
+ }
664
+
635
665
  // Create feed subscription
636
666
  const feed = await createFeed(application, {
637
667
  channelId: channelDocument._id,
@@ -675,6 +705,210 @@ export async function markAllRead(request, response) {
675
705
  response.redirect(`${request.baseUrl}/channels/${channelUid}`);
676
706
  }
677
707
 
708
+ /**
709
+ * View single feed details with status - redirects to edit form
710
+ * @param {object} request - Express request
711
+ * @param {object} response - Express response
712
+ * @returns {Promise<void>}
713
+ */
714
+ export async function feedDetails(request, response) {
715
+ const { uid, feedId } = request.params;
716
+ // Redirect to edit form which shows all details
717
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds/${feedId}/edit`);
718
+ }
719
+
720
+ /**
721
+ * Edit feed URL form
722
+ * @param {object} request - Express request
723
+ * @param {object} response - Express response
724
+ * @returns {Promise<void>}
725
+ */
726
+ export async function editFeedForm(request, response) {
727
+ const { application } = request.app.locals;
728
+ const userId = getUserId(request);
729
+ const { uid, feedId } = request.params;
730
+
731
+ const channelDocument = await getChannel(application, uid, userId);
732
+ if (!channelDocument) {
733
+ return response.status(404).render("404");
734
+ }
735
+
736
+ const feed = await getFeedById(application, feedId);
737
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
738
+ return response.status(404).render("404");
739
+ }
740
+
741
+ response.render("feed-edit", {
742
+ title: request.__("microsub.feeds.edit"),
743
+ channel: channelDocument,
744
+ feed,
745
+ baseUrl: request.baseUrl,
746
+ });
747
+ }
748
+
749
+ /**
750
+ * Update feed URL
751
+ * @param {object} request - Express request
752
+ * @param {object} response - Express response
753
+ * @returns {Promise<void>}
754
+ */
755
+ export async function updateFeedUrl(request, response) {
756
+ const { application } = request.app.locals;
757
+ const userId = getUserId(request);
758
+ const { uid, feedId } = request.params;
759
+ const { url: newUrl } = request.body;
760
+
761
+ const channelDocument = await getChannel(application, uid, userId);
762
+ if (!channelDocument) {
763
+ return response.status(404).render("404");
764
+ }
765
+
766
+ const feed = await getFeedById(application, feedId);
767
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
768
+ return response.status(404).render("404");
769
+ }
770
+
771
+ // Validate the new URL is a valid feed
772
+ const validation = await validateFeedUrl(newUrl);
773
+
774
+ if (!validation.valid) {
775
+ return response.render("feed-edit", {
776
+ title: request.__("microsub.feeds.edit"),
777
+ channel: channelDocument,
778
+ feed,
779
+ error: validation.error,
780
+ baseUrl: request.baseUrl,
781
+ });
782
+ }
783
+
784
+ // Update the feed URL and reset error state
785
+ await updateFeed(application, feedId, {
786
+ url: newUrl,
787
+ title: validation.title || feed.title,
788
+ status: "active",
789
+ lastError: undefined,
790
+ lastErrorAt: undefined,
791
+ consecutiveErrors: 0,
792
+ });
793
+
794
+ // Trigger immediate fetch
795
+ refreshFeedNow(application, feedId).catch((error) => {
796
+ console.error(
797
+ `[Microsub] Error refreshing updated feed ${newUrl}:`,
798
+ error.message,
799
+ );
800
+ });
801
+
802
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
803
+ }
804
+
805
+ /**
806
+ * Rediscover feed - run discovery on URL to find actual RSS feed
807
+ * @param {object} request - Express request
808
+ * @param {object} response - Express response
809
+ * @returns {Promise<void>}
810
+ */
811
+ export async function rediscoverFeed(request, response) {
812
+ const { application } = request.app.locals;
813
+ const userId = getUserId(request);
814
+ const { uid, feedId } = request.params;
815
+
816
+ const channelDocument = await getChannel(application, uid, userId);
817
+ if (!channelDocument) {
818
+ return response.status(404).render("404");
819
+ }
820
+
821
+ const feed = await getFeedById(application, feedId);
822
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
823
+ return response.status(404).render("404");
824
+ }
825
+
826
+ // Run feed discovery on the current URL
827
+ try {
828
+ const discoveredFeeds = await discoverAndValidateFeeds(feed.url);
829
+ const bestFeed = getBestFeed(discoveredFeeds);
830
+
831
+ if (bestFeed && bestFeed.url !== feed.url) {
832
+ // Found a different (better) feed URL - update the record
833
+ await updateFeed(application, feedId, {
834
+ url: bestFeed.url,
835
+ title: bestFeed.title || feed.title,
836
+ status: "active",
837
+ lastError: undefined,
838
+ lastErrorAt: undefined,
839
+ consecutiveErrors: 0,
840
+ });
841
+
842
+ console.info(
843
+ `[Microsub] Rediscovered feed: ${feed.url} -> ${bestFeed.url}`,
844
+ );
845
+
846
+ // Trigger immediate fetch
847
+ refreshFeedNow(application, feedId).catch((error) => {
848
+ console.error(
849
+ `[Microsub] Error refreshing rediscovered feed:`,
850
+ error.message,
851
+ );
852
+ });
853
+ } else if (bestFeed) {
854
+ // Same URL but valid - just reset error state and refresh
855
+ await updateFeedStatus(application, feedId, { success: true });
856
+ await updateFeed(application, feedId, {
857
+ status: "active",
858
+ lastError: undefined,
859
+ lastErrorAt: undefined,
860
+ consecutiveErrors: 0,
861
+ });
862
+
863
+ refreshFeedNow(application, feedId).catch((error) => {
864
+ console.error(`[Microsub] Error refreshing feed:`, error.message);
865
+ });
866
+ } else {
867
+ // No valid feed found
868
+ await updateFeedStatus(application, feedId, {
869
+ success: false,
870
+ error: "No valid feed found at this URL",
871
+ });
872
+ }
873
+ } catch (error) {
874
+ await updateFeedStatus(application, feedId, {
875
+ success: false,
876
+ error: error.message,
877
+ });
878
+ }
879
+
880
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
881
+ }
882
+
883
+ /**
884
+ * Force refresh a feed
885
+ * @param {object} request - Express request
886
+ * @param {object} response - Express response
887
+ * @returns {Promise<void>}
888
+ */
889
+ export async function refreshFeed(request, response) {
890
+ const { application } = request.app.locals;
891
+ const userId = getUserId(request);
892
+ const { uid, feedId } = request.params;
893
+
894
+ const channelDocument = await getChannel(application, uid, userId);
895
+ if (!channelDocument) {
896
+ return response.status(404).render("404");
897
+ }
898
+
899
+ const feed = await getFeedById(application, feedId);
900
+ if (!feed || feed.channelId.toString() !== channelDocument._id.toString()) {
901
+ return response.status(404).render("404");
902
+ }
903
+
904
+ // Trigger immediate fetch
905
+ refreshFeedNow(application, feedId).catch((error) => {
906
+ console.error(`[Microsub] Error refreshing feed ${feed.url}:`, error.message);
907
+ });
908
+
909
+ response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
910
+ }
911
+
678
912
  export const readerController = {
679
913
  index,
680
914
  channels,
@@ -688,6 +922,11 @@ export const readerController = {
688
922
  feeds,
689
923
  addFeed,
690
924
  removeFeed,
925
+ feedDetails,
926
+ editFeedForm,
927
+ updateFeedUrl,
928
+ rediscoverFeed,
929
+ refreshFeed,
691
930
  item,
692
931
  compose,
693
932
  submitCompose,
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Enhanced feed discovery with type labels and validation
3
+ * @module feeds/discovery
4
+ */
5
+
6
+ import { discoverFeedsFromUrl } from "./fetcher.js";
7
+ import { validateFeedUrl } from "./validator.js";
8
+
9
+ /**
10
+ * Feed type display labels
11
+ */
12
+ const FEED_TYPE_LABELS = {
13
+ rss: "RSS Feed",
14
+ atom: "Atom Feed",
15
+ jsonfeed: "JSON Feed",
16
+ hfeed: "h-feed (Microformats)",
17
+ activitypub: "ActivityPub",
18
+ unknown: "Unknown",
19
+ };
20
+
21
+ /**
22
+ * Discover and validate all feeds from a URL
23
+ * @param {string} url - Page or feed URL
24
+ * @returns {Promise<Array>} Array of discovered feeds with validation status
25
+ */
26
+ export async function discoverAndValidateFeeds(url) {
27
+ // First discover feeds from the URL
28
+ const feeds = await discoverFeedsFromUrl(url);
29
+
30
+ // If no feeds found, return empty with error info
31
+ if (feeds.length === 0) {
32
+ return [
33
+ {
34
+ url,
35
+ type: "unknown",
36
+ typeLabel: "No feed found",
37
+ valid: false,
38
+ error: "No feeds were discovered at this URL",
39
+ isCommentsFeed: false,
40
+ },
41
+ ];
42
+ }
43
+
44
+ // Validate each discovered feed in parallel
45
+ const validatedFeeds = await Promise.all(
46
+ feeds.map(async (feed) => {
47
+ const validation = await validateFeedUrl(feed.url);
48
+
49
+ return {
50
+ url: feed.url,
51
+ type: validation.feedType || feed.type,
52
+ typeLabel:
53
+ FEED_TYPE_LABELS[validation.feedType] ||
54
+ FEED_TYPE_LABELS[feed.type] ||
55
+ "Feed",
56
+ valid: validation.valid,
57
+ error: validation.error,
58
+ isCommentsFeed: validation.isCommentsFeed || false,
59
+ title: validation.title || feed.title,
60
+ rel: feed.rel,
61
+ };
62
+ }),
63
+ );
64
+
65
+ // Sort: valid feeds first, non-comments before comments, then alphabetically
66
+ return validatedFeeds.sort((a, b) => {
67
+ // Valid feeds first
68
+ if (a.valid !== b.valid) return a.valid ? -1 : 1;
69
+ // Non-comments before comments
70
+ if (a.isCommentsFeed !== b.isCommentsFeed) return a.isCommentsFeed ? 1 : -1;
71
+ // Then by URL
72
+ return a.url.localeCompare(b.url);
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Filter to only main content feeds (exclude comments)
78
+ * @param {Array} feeds - Array of feed objects
79
+ * @returns {Array} Filtered array of main content feeds
80
+ */
81
+ export function filterMainFeeds(feeds) {
82
+ return feeds.filter((feed) => feed.valid && !feed.isCommentsFeed);
83
+ }
84
+
85
+ /**
86
+ * Get the best feed from a list (first valid, non-comments feed)
87
+ * @param {Array} feeds - Array of feed objects
88
+ * @returns {object|undefined} Best feed or undefined
89
+ */
90
+ export function getBestFeed(feeds) {
91
+ const mainFeeds = filterMainFeeds(feeds);
92
+ return mainFeeds.length > 0 ? mainFeeds[0] : undefined;
93
+ }
94
+
95
+ export { FEED_TYPE_LABELS };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Feed validation utilities
3
+ * @module feeds/validator
4
+ */
5
+
6
+ import { fetchFeed } from "./fetcher.js";
7
+ import { detectFeedType } from "./parser.js";
8
+
9
+ /**
10
+ * Feed types that are valid subscriptions
11
+ */
12
+ const VALID_FEED_TYPES = ["rss", "atom", "jsonfeed", "hfeed"];
13
+
14
+ /**
15
+ * Patterns that indicate a comments feed (not a main feed)
16
+ */
17
+ const COMMENTS_PATTERNS = [
18
+ /\/comments\/?$/i,
19
+ /\/feed\/comments/i,
20
+ /commentsfeed/i,
21
+ /comment-feed/i,
22
+ /-comments\.xml$/i,
23
+ /\/replies\/?$/i,
24
+ /comments\.rss$/i,
25
+ /comments\.atom$/i,
26
+ ];
27
+
28
+ /**
29
+ * Validate a URL is actually a feed
30
+ * @param {string} url - URL to validate
31
+ * @returns {Promise<object>} Validation result
32
+ */
33
+ export async function validateFeedUrl(url) {
34
+ try {
35
+ const result = await fetchFeed(url, { timeout: 15000 });
36
+
37
+ if (result.notModified || !result.content) {
38
+ return {
39
+ valid: false,
40
+ error: "Unable to fetch content from URL",
41
+ };
42
+ }
43
+
44
+ const feedType = detectFeedType(result.content, result.contentType);
45
+
46
+ if (feedType === "activitypub") {
47
+ return {
48
+ valid: false,
49
+ error:
50
+ "URL returns ActivityPub JSON instead of a feed. Try the direct feed URL.",
51
+ feedType,
52
+ };
53
+ }
54
+
55
+ if (!VALID_FEED_TYPES.includes(feedType)) {
56
+ return {
57
+ valid: false,
58
+ error: `URL does not contain a valid feed (detected: ${feedType})`,
59
+ feedType,
60
+ };
61
+ }
62
+
63
+ // Check if it's a comments feed
64
+ const isCommentsFeed = COMMENTS_PATTERNS.some((pattern) =>
65
+ pattern.test(url),
66
+ );
67
+
68
+ return {
69
+ valid: true,
70
+ feedType,
71
+ isCommentsFeed,
72
+ title: extractFeedTitle(result.content, feedType),
73
+ contentType: result.contentType,
74
+ };
75
+ } catch (error) {
76
+ return {
77
+ valid: false,
78
+ error: error.message,
79
+ };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Extract feed title from content
85
+ * @param {string} content - Feed content
86
+ * @param {string} feedType - Type of feed
87
+ * @returns {string|undefined} Feed title
88
+ */
89
+ function extractFeedTitle(content, feedType) {
90
+ if (feedType === "jsonfeed") {
91
+ try {
92
+ const json = JSON.parse(content);
93
+ return json.title;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ // Extract title from XML (RSS or Atom)
100
+ // Try channel/title first (RSS), then just title (Atom)
101
+ const channelTitleMatch = content.match(
102
+ /<channel[^>]*>[\s\S]*?<title[^>]*>([^<]+)<\/title>/i,
103
+ );
104
+ if (channelTitleMatch) {
105
+ return decodeXmlEntities(channelTitleMatch[1].trim());
106
+ }
107
+
108
+ const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i);
109
+ return titleMatch ? decodeXmlEntities(titleMatch[1].trim()) : undefined;
110
+ }
111
+
112
+ /**
113
+ * Decode XML entities
114
+ * @param {string} str - String with XML entities
115
+ * @returns {string} Decoded string
116
+ */
117
+ function decodeXmlEntities(str) {
118
+ return str
119
+ .replace(/&amp;/g, "&")
120
+ .replace(/&lt;/g, "<")
121
+ .replace(/&gt;/g, ">")
122
+ .replace(/&quot;/g, '"')
123
+ .replace(/&apos;/g, "'")
124
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
125
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, code) =>
126
+ String.fromCharCode(parseInt(code, 16)),
127
+ );
128
+ }
@@ -6,7 +6,11 @@
6
6
  import { getRedisClient, publishEvent } from "../cache/redis.js";
7
7
  import { fetchAndParseFeed } from "../feeds/fetcher.js";
8
8
  import { getChannel } from "../storage/channels.js";
9
- import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
9
+ import {
10
+ updateFeedAfterFetch,
11
+ updateFeedStatus,
12
+ updateFeedWebsub,
13
+ } from "../storage/feeds.js";
10
14
  import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
11
15
  import { addItem } from "../storage/items.js";
12
16
  import {
@@ -167,9 +171,21 @@ export async function processFeed(application, feed) {
167
171
 
168
172
  result.success = true;
169
173
  result.tier = tierResult.tier;
174
+
175
+ // Update feed status to active on success
176
+ await updateFeedStatus(application, feed._id, {
177
+ success: true,
178
+ itemCount: parsed.items?.length || 0,
179
+ });
170
180
  } catch (error) {
171
181
  result.error = error.message;
172
182
 
183
+ // Update feed status to error
184
+ await updateFeedStatus(application, feed._id, {
185
+ success: false,
186
+ error: error.message,
187
+ });
188
+
173
189
  // Still update the feed to prevent retry storms
174
190
  try {
175
191
  const tierResult = calculateNewTier({
@@ -182,8 +198,6 @@ export async function processFeed(application, feed) {
182
198
  tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
183
199
  unmodified: tierResult.consecutiveUnchanged,
184
200
  nextFetchAt: tierResult.nextFetchAt,
185
- lastError: error.message,
186
- lastErrorAt: new Date(),
187
201
  });
188
202
  } catch {
189
203
  // Ignore update errors
@@ -297,3 +297,68 @@ export async function updateFeedWebsub(application, id, websub) {
297
297
  export async function getFeedBySubscriptionId(application, subscriptionId) {
298
298
  return getFeedById(application, subscriptionId);
299
299
  }
300
+
301
+ /**
302
+ * Update feed status after processing
303
+ * Tracks health status, errors, and success metrics
304
+ * @param {object} application - Indiekit application
305
+ * @param {ObjectId|string} id - Feed ObjectId
306
+ * @param {object} status - Status update
307
+ * @param {boolean} status.success - Whether fetch was successful
308
+ * @param {string} [status.error] - Error message if failed
309
+ * @param {number} [status.itemCount] - Number of items in feed
310
+ * @returns {Promise<object|null>} Updated feed
311
+ */
312
+ export async function updateFeedStatus(application, id, status) {
313
+ const collection = getCollection(application);
314
+ const objectId = typeof id === "string" ? new ObjectId(id) : id;
315
+
316
+ const updateFields = {
317
+ updatedAt: new Date(),
318
+ };
319
+
320
+ if (status.success) {
321
+ updateFields.status = "active";
322
+ updateFields.lastSuccessAt = new Date();
323
+ updateFields.consecutiveErrors = 0;
324
+ updateFields.lastError = undefined;
325
+ updateFields.lastErrorAt = undefined;
326
+
327
+ if (status.itemCount !== undefined) {
328
+ updateFields.itemCount = status.itemCount;
329
+ }
330
+ } else {
331
+ updateFields.status = "error";
332
+ updateFields.lastError = status.error;
333
+ updateFields.lastErrorAt = new Date();
334
+ }
335
+
336
+ // Use $set for most fields, $inc for consecutiveErrors on failure
337
+ const updateOp = { $set: updateFields };
338
+
339
+ if (!status.success) {
340
+ // Increment consecutive errors
341
+ updateOp.$inc = { consecutiveErrors: 1 };
342
+ }
343
+
344
+ return collection.findOneAndUpdate({ _id: objectId }, updateOp, {
345
+ returnDocument: "after",
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Get feeds with errors
351
+ * @param {object} application - Indiekit application
352
+ * @param {number} [minErrors=3] - Minimum consecutive errors
353
+ * @returns {Promise<Array>} Array of feeds with errors
354
+ */
355
+ export async function getFeedsWithErrors(application, minErrors = 3) {
356
+ const collection = getCollection(application);
357
+
358
+ return collection
359
+ .find({
360
+ status: "error",
361
+ consecutiveErrors: { $gte: minErrors },
362
+ })
363
+ .toArray();
364
+ }
@@ -328,6 +328,68 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
328
328
  }
329
329
  }
330
330
 
331
+ /**
332
+ * Cleanup all read items across all channels (startup cleanup)
333
+ * @param {object} application - Indiekit application
334
+ * @returns {Promise<number>} Total number of items deleted
335
+ */
336
+ export async function cleanupAllReadItems(application) {
337
+ const collection = getCollection(application);
338
+ const channelsCollection = application.collections.get("microsub_channels");
339
+
340
+ // Get all channels
341
+ const channels = await channelsCollection.find({}).toArray();
342
+ let totalDeleted = 0;
343
+
344
+ for (const channel of channels) {
345
+ // Get unique userIds who have read items in this channel
346
+ const readByUsers = await collection.distinct("readBy", {
347
+ channelId: channel._id,
348
+ readBy: { $exists: true, $ne: [] },
349
+ });
350
+
351
+ for (const userId of readByUsers) {
352
+ if (!userId) continue;
353
+
354
+ const readCount = await collection.countDocuments({
355
+ channelId: channel._id,
356
+ readBy: userId,
357
+ });
358
+
359
+ if (readCount > MAX_READ_ITEMS) {
360
+ const itemsToDelete = await collection
361
+ .find({
362
+ channelId: channel._id,
363
+ readBy: userId,
364
+ })
365
+ .sort({ published: -1, _id: -1 })
366
+ .skip(MAX_READ_ITEMS)
367
+ .project({ _id: 1 })
368
+ .toArray();
369
+
370
+ if (itemsToDelete.length > 0) {
371
+ const idsToDelete = itemsToDelete.map((item) => item._id);
372
+ const deleteResult = await collection.deleteMany({
373
+ _id: { $in: idsToDelete },
374
+ });
375
+ totalDeleted += deleteResult.deletedCount;
376
+ console.info(
377
+ `[Microsub] Startup cleanup: deleted ${deleteResult.deletedCount} old items from channel "${channel.name}"`,
378
+ );
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ if (totalDeleted > 0) {
385
+ console.info(
386
+ `[Microsub] Startup cleanup complete: ${totalDeleted} total items deleted`,
387
+ );
388
+ }
389
+
390
+ return totalDeleted;
391
+ }
392
+
331
393
  export async function markItemsRead(application, channelId, entryIds, userId) {
332
394
  const collection = getCollection(application);
333
395
  const channelObjectId =
package/locales/en.json CHANGED
@@ -34,7 +34,15 @@
34
34
  "unfollow": "Unfollow",
35
35
  "empty": "No feeds followed in this channel",
36
36
  "url": "Feed URL",
37
- "urlPlaceholder": "https://example.com/feed.xml"
37
+ "urlPlaceholder": "https://example.com/feed.xml",
38
+ "edit": "Edit feed",
39
+ "rediscover": "Rediscover feed",
40
+ "refresh": "Refresh now",
41
+ "status": {
42
+ "active": "Active",
43
+ "error": "Error",
44
+ "stale": "Stale"
45
+ }
38
46
  },
39
47
  "item": {
40
48
  "reply": "Reply",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.21",
3
+ "version": "1.0.24",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,84 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% block reader %}
4
+ <div class="settings">
5
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="back-link">
6
+ {{ icon("previous") }} {{ __("microsub.feeds.title") }}
7
+ </a>
8
+
9
+ <h2>{{ __("microsub.feeds.edit") }}</h2>
10
+
11
+ {% if error %}
12
+ <div class="notice notice--error">
13
+ <p>{{ error }}</p>
14
+ </div>
15
+ {% endif %}
16
+
17
+ <div class="feed-edit">
18
+ <div class="feed-edit__current">
19
+ <h3>Current Feed</h3>
20
+ <p class="feed-edit__url">{{ feed.url }}</p>
21
+ {% if feed.title %}
22
+ <p class="feed-edit__title">{{ feed.title }}</p>
23
+ {% endif %}
24
+ {% if feed.status == 'error' %}
25
+ <div class="notice notice--error">
26
+ <p><strong>Status:</strong> Error</p>
27
+ {% if feed.lastError %}
28
+ <p><strong>Last error:</strong> {{ feed.lastError }}</p>
29
+ {% endif %}
30
+ {% if feed.consecutiveErrors %}
31
+ <p><strong>Consecutive errors:</strong> {{ feed.consecutiveErrors }}</p>
32
+ {% endif %}
33
+ </div>
34
+ {% endif %}
35
+ </div>
36
+
37
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit" class="feed-edit__form">
38
+ {{ input({
39
+ id: "url",
40
+ name: "url",
41
+ label: "New Feed URL",
42
+ type: "url",
43
+ required: true,
44
+ value: feed.url,
45
+ placeholder: "https://example.com/feed.xml",
46
+ autocomplete: "off"
47
+ }) }}
48
+
49
+ <p class="feed-edit__help">
50
+ Enter the direct URL to the RSS, Atom, or JSON Feed. The URL will be validated before updating.
51
+ </p>
52
+
53
+ <div class="button-group">
54
+ {{ button({ text: "Update Feed URL" }) }}
55
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary">
56
+ Cancel
57
+ </a>
58
+ </div>
59
+ </form>
60
+
61
+ <div class="divider"></div>
62
+
63
+ <div class="feed-edit__actions">
64
+ <h3>Other Actions</h3>
65
+
66
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" class="feed-edit__action">
67
+ <p>Run feed discovery on the current URL to find the actual RSS/Atom feed.</p>
68
+ {{ button({
69
+ text: "Rediscover Feed",
70
+ classes: "button--secondary"
71
+ }) }}
72
+ </form>
73
+
74
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" class="feed-edit__action">
75
+ <p>Force refresh this feed now.</p>
76
+ {{ button({
77
+ text: "Refresh Now",
78
+ classes: "button--secondary"
79
+ }) }}
80
+ </form>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ {% endblock %}
package/views/feeds.njk CHANGED
@@ -13,7 +13,7 @@
13
13
  {% if feeds.length > 0 %}
14
14
  <div class="feeds__list">
15
15
  {% for feed in feeds %}
16
- <div class="feeds__item">
16
+ <div class="feeds__item{% if feed.status == 'error' %} feeds__item--error{% endif %}">
17
17
  <div class="feeds__info">
18
18
  {% if feed.photo %}
19
19
  <img src="{{ feed.photo }}"
@@ -25,19 +25,51 @@
25
25
  onerror="this.style.display='none'">
26
26
  {% endif %}
27
27
  <div class="feeds__details">
28
- <span class="feeds__name">{{ feed.title or feed.url }}</span>
28
+ <span class="feeds__name">
29
+ {{ feed.title or feed.url }}
30
+ {% if feed.status == 'error' %}
31
+ <span class="badge badge--red">Error</span>
32
+ {% elif feed.status == 'active' %}
33
+ <span class="badge badge--green">Active</span>
34
+ {% endif %}
35
+ </span>
29
36
  <a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">
30
37
  {{ feed.url | replace("https://", "") | replace("http://", "") }}
31
38
  </a>
39
+ {% if feed.lastError %}
40
+ <span class="feeds__error">{{ feed.lastError }}</span>
41
+ {% endif %}
42
+ {% if feed.consecutiveErrors > 0 %}
43
+ <span class="feeds__error-count">{{ feed.consecutiveErrors }} consecutive errors</span>
44
+ {% endif %}
45
+ {% if feed.lastSuccessAt %}
46
+ <span class="feeds__meta">Last success: {{ feed.lastSuccessAt | date("relative") }}</span>
47
+ {% endif %}
32
48
  </div>
33
49
  </div>
34
- <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" class="feeds__actions">
35
- <input type="hidden" name="url" value="{{ feed.url }}">
36
- {{ button({
37
- text: __("microsub.feeds.unfollow"),
38
- classes: "button--secondary button--small"
39
- }) }}
40
- </form>
50
+ <div class="feeds__actions">
51
+ <a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/edit"
52
+ class="button button--secondary button--small"
53
+ title="Edit feed URL">
54
+ {{ icon("edit") }}
55
+ </a>
56
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/rediscover" style="display:inline;">
57
+ <button type="submit" class="button button--secondary button--small" title="Rediscover feed">
58
+ {{ icon("discover") }}
59
+ </button>
60
+ </form>
61
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/{{ feed._id }}/refresh" style="display:inline;">
62
+ <button type="submit" class="button button--secondary button--small" title="Refresh now">
63
+ {{ icon("refresh") }}
64
+ </button>
65
+ </form>
66
+ <form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" style="display:inline;">
67
+ <input type="hidden" name="url" value="{{ feed.url }}">
68
+ <button type="submit" class="button button--warning button--small" title="Unfollow">
69
+ {{ icon("delete") }}
70
+ </button>
71
+ </form>
72
+ </div>
41
73
  </div>
42
74
  {% endfor %}
43
75
  </div>
package/views/search.njk CHANGED
@@ -25,16 +25,40 @@
25
25
  </div>
26
26
  </form>
27
27
 
28
+ {% if validationError %}
29
+ <div class="notice notice--error">
30
+ <p>{{ validationError }}</p>
31
+ </div>
32
+ {% endif %}
33
+
34
+ {% if discoveryError %}
35
+ <div class="notice notice--error">
36
+ <p>{{ discoveryError }}</p>
37
+ </div>
38
+ {% endif %}
39
+
28
40
  {% if results and results.length > 0 %}
29
41
  <div class="search__results">
30
42
  <h3>{{ __("microsub.search.title") }}</h3>
31
43
  <div class="search__list">
32
44
  {% for result in results %}
33
- <div class="search__item">
45
+ <div class="search__item{% if not result.valid %} search__item--invalid{% endif %}{% if result.isCommentsFeed %} search__item--comments{% endif %}">
34
46
  <div class="search__feed">
35
- <span class="search__name">{{ result.title or "Feed" }}</span>
47
+ <span class="search__name">
48
+ {{ result.title or "Feed" }}
49
+ <span class="search__type badge badge--small{% if result.valid %} badge--green{% else %} badge--yellow{% endif %}">
50
+ {{ result.typeLabel }}
51
+ </span>
52
+ {% if result.isCommentsFeed %}
53
+ <span class="search__type badge badge--small badge--yellow">Comments</span>
54
+ {% endif %}
55
+ </span>
36
56
  <span class="search__url">{{ result.url | replace("https://", "") | replace("http://", "") }}</span>
57
+ {% if not result.valid %}
58
+ <span class="search__error">{{ result.error }}</span>
59
+ {% endif %}
37
60
  </div>
61
+ {% if result.valid %}
38
62
  <form method="post" action="{{ baseUrl }}/subscribe" class="search__subscribe">
39
63
  <input type="hidden" name="url" value="{{ result.url }}">
40
64
  <label for="channel-{{ loop.index }}" class="visually-hidden">{{ __("microsub.channels.title") }}</label>
@@ -48,6 +72,9 @@
48
72
  classes: "button--small"
49
73
  }) }}
50
74
  </form>
75
+ {% else %}
76
+ <span class="badge badge--small badge--red">Invalid</span>
77
+ {% endif %}
51
78
  </div>
52
79
  {% endfor %}
53
80
  </div>