Repository: getindiekit/indiekit (js)

Issue:
Not having a syndicator correctly configured prevents adding new posts  
### Description of the bug

The Railway template asks that you provide optional Mastodon details. If you don’t provide any values, the interface for adding a post returns a 500 error, with message that Mastodon syndicator not setup.

### Steps to reproduce

_No response_

### Expected behaviour

- Syndicator errors don’t prevent editing interface from being shown
- Provide inline message alongside syndicators that have been added but not configured


Pull request:
Show syndication target errors
Instead of throwing 500 error, show syndicator target errors in the posting interface:

<img width="260" alt="Screenshot 2024-03-10 at 23 06 58" src="https://github.com/getindiekit/indiekit/assets/813383/49470a7b-2915-44b4-b8cd-aea2e74de41c">

Fixes #710

## To do

- [x] Update tests
- [x] Update `Indiekit.addSyndicator` documentation

Patch:
diff --git a/docs/plugins/api/add-syndicator.md b/docs/plugins/api/add-syndicator.md
index 2c0b6f28..ae9f5165 100644
--- a/docs/plugins/api/add-syndicator.md
+++ b/docs/plugins/api/add-syndicator.md
@@ -31,6 +31,9 @@ new Indiekit.addSyndicator(options);
   `checked`
   : A boolean indicating whether this syndicator should be enabled by default in Micropub clients.
 
+  `error`
+  : Information about any configuration errors. This will be shown in Indiekit’s interface.
+
   `service`
   : An object containing information about the third-party service.
 
diff --git a/packages/endpoint-posts/lib/utils.js b/packages/endpoint-posts/lib/utils.js
index a1cc201c..dfe7fcd4 100644
--- a/packages/endpoint-posts/lib/utils.js
+++ b/packages/endpoint-posts/lib/utils.js
@@ -151,8 +151,15 @@ export const getPostUrl = (id) => {
 export const getSyndicateToItems = (publication, checkTargets = false) => {
   return publication.syndicationTargets.map((target) => ({
     label: target.info.service.name,
-    hint: target.info.uid,
-    value: target.info.uid,
-    ...(checkTargets && { checked: target.options.checked }),
+    ...(target?.info?.error
+      ? {
+          disabled: true,
+          hint: target?.info?.error || false,
+        }
+      : {
+          hint: target?.info.uid,
+          value: target?.info.uid,
+          ...(checkTargets && { checked: target.options.checked }),
+        }),
   }));
 };
diff --git a/packages/syndicator-internet-archive/index.js b/packages/syndicator-internet-archive/index.js
index 74c0c835..0f7c32c1 100644
--- a/packages/syndicator-internet-archive/index.js
+++ b/packages/syndicator-internet-archive/index.js
@@ -27,15 +27,31 @@ export default class InternetArchiveSyndicator {
   }
 
   get info() {
+    const service = {
+      name: "Internet Archive",
+      url: "https://web.archive.org/",
+      photo: "/assets/internet-archive/icon.svg",
+    };
+
+    if (!this.options?.accessKey) {
+      return {
+        error: "Access key required",
+        service,
+      };
+    }
+
+    if (!this.options?.secretKey) {
+      return {
+        error: "Secret key required",
+        service,
+      };
+    }
+
     return {
       checked: this.options.checked,
       name: this.options.name,
       uid: this.options.uid,
-      service: {
-        name: "Internet Archive",
-        url: "https://web.archive.org/",
-        photo: "/assets/internet-archive/icon.svg",
-      },
+      service,
     };
   }
 
diff --git a/packages/syndicator-mastodon/index.js b/packages/syndicator-mastodon/index.js
index dc80ef4d..087b75f3 100644
--- a/packages/syndicator-mastodon/index.js
+++ b/packages/syndicator-mastodon/index.js
@@ -26,19 +26,13 @@ export default class MastodonSyndicator {
   }
 
   get #url() {
-    if (this.options.url) {
-      return new URL(this.options.url);
-    }
-
-    throw new Error("Mastodon server URL required");
+    return this.options?.url ? new URL(this.options.url) : false;
   }
 
   get #user() {
-    if (this.options.user) {
-      return `@${this.options.user.replace("@", "")}`;
-    }
-
-    throw new Error("Mastodon user name required");
+    return this.options?.user
+      ? `@${this.options.user.replace("@", "")}`
+      : false;
   }
 
   get environment() {
@@ -46,20 +40,35 @@ export default class MastodonSyndicator {
   }
 
   get info() {
-    const { checked } = this.options;
+    const service = {
+      name: "Mastodon",
+      photo: "/assets/mastodon/icon.svg",
+    };
     const user = this.#user;
     const url = this.#url;
+
+    if (!url) {
+      return {
+        error: "Server URL required",
+        service,
+      };
+    }
+
+    if (!user) {
+      return {
+        error: "User name required",
+        service,
+      };
+    }
+
     const uid = `${url.protocol}//${path.join(url.hostname, user)}`;
+    service.url = url.href;
 
     return {
-      checked,
+      checked: this.options.checked,
       name: `${user}@${url.hostname}`,
       uid,
-      service: {
-        name: "Mastodon",
-        url: url.href,
-        photo: "/assets/mastodon/icon.svg",
-      },
+      service,
       user: {
         name: user,
         url: uid,


Test patch:
diff --git a/packages/endpoint-posts/test/unit/utils.js b/packages/endpoint-posts/test/unit/utils.js
index 4566682d..9dba26c3 100644
--- a/packages/endpoint-posts/test/unit/utils.js
+++ b/packages/endpoint-posts/test/unit/utils.js
@@ -29,6 +29,14 @@ const publication = {
         checked: true,
       },
     },
+    {
+      info: {
+        error: "Secret key required",
+        service: {
+          name: "Internet Archive",
+        },
+      },
+    },
   ],
 };
 
@@ -225,10 +233,14 @@ describe("endpoint-posts/lib/utils", () => {
   it("Gets syndication target `items` for checkboxes component", () => {
     const result = getSyndicateToItems(publication, true);
 
-    assert.equal(result.length, 1);
+    assert.equal(result.length, 2);
     assert.equal(result[0].checked, true);
     assert.equal(result[0].hint, "https://mastodon.example/@username");
     assert.equal(result[0].label, "Mastodon");
     assert.equal(result[0].value, "https://mastodon.example/@username");
+    assert.equal(result[1].checked, undefined);
+    assert.equal(result[1].hint, "Secret key required");
+    assert.equal(result[1].label, "Internet Archive");
+    assert.equal(result[1].value, undefined);
   });
 });
diff --git a/packages/syndicator-internet-archive/test/index.js b/packages/syndicator-internet-archive/test/index.js
index 9f587a5f..bff3dc6a 100644
--- a/packages/syndicator-internet-archive/test/index.js
+++ b/packages/syndicator-internet-archive/test/index.js
@@ -29,6 +29,22 @@ describe("syndicator-internet-archive", () => {
     assert.ok(internetArchive.info.service);
   });
 
+  it("Returns error information if no secret key provided", async () => {
+    const result = new InternetArchiveSyndicator({
+      accessKey: "token",
+    });
+
+    assert.equal(result.info.error, "Secret key required");
+  });
+
+  it("Returns error information if no access key provided", () => {
+    const result = new InternetArchiveSyndicator({
+      secretKey: "secret",
+    });
+
+    assert.equal(result.info.error, "Access key required");
+  });
+
   it("Initiates plug-in", async () => {
     const indiekit = await Indiekit.initialize({ config: {} });
     internetArchive.init(indiekit);
diff --git a/packages/syndicator-mastodon/test/index.js b/packages/syndicator-mastodon/test/index.js
index 35730e86..22355be5 100644
--- a/packages/syndicator-mastodon/test/index.js
+++ b/packages/syndicator-mastodon/test/index.js
@@ -34,6 +34,24 @@ describe("syndicator-mastodon", () => {
     assert.ok(mastodon.info.service);
   });
 
+  it("Returns error information if no server URL provided", async () => {
+    const result = new MastodonSyndicator({
+      accessToken: "token",
+      user: "username",
+    });
+
+    assert.equal(result.info.error, "Server URL required");
+  });
+
+  it("Returns error information if no username provided", () => {
+    const result = new MastodonSyndicator({
+      accessToken: "token",
+      url: "https://mastodon.example",
+    });
+
+    assert.equal(result.info.error, "User name required");
+  });
+
   it("Gets plug-in installation prompts", () => {
     assert.equal(
       mastodon.prompts[0].message,
@@ -60,31 +78,6 @@ describe("syndicator-mastodon", () => {
     );
   });
 
-  it("Throws error getting syndicated URL if no server URL provided", async () => {
-    const mastodonNoServer = new MastodonSyndicator({
-      accessToken: "token",
-      user: "username",
-    });
-
-    await assert.rejects(mastodonNoServer.syndicate(properties, publication), {
-      message: "Mastodon syndicator: Mastodon server URL required",
-    });
-  });
-
-  it("Throws error getting username if no username provided", () => {
-    const mastodonNoUser = new MastodonSyndicator({
-      accessToken: "token",
-      url: "https://mastodon.example",
-    });
-
-    assert.throws(
-      () => {
-        mastodonNoUser.info.name;
-      },
-      { message: "Mastodon user name required" },
-    );
-  });
-
   it("Throws error getting syndicated URL if access token invalid", async () => {
     const mastodonNoToken = new MastodonSyndicator({
       accessToken: "invalid",