Repository: getindiekit/indiekit
Pull request #714 (2024-03-13 00:38:14)

Web app manifest shortcuts
Adds support for endpoints to supply [`shortcuts`](https://developer.mozilla.org/en-US/docs/Web/Manifest/shortcuts) to the web app manifest:

<img width="400" alt="Shortcut items in File menu in macOS application." src="https://github.com/getindiekit/indiekit/assets/813383/a4b0a81f-2412-4b4b-a2e8-ff36b0fcedee">


Patch:
diff --git a/docs/plugins/api/add-endpoint.md b/docs/plugins/api/add-endpoint.md
index c161a90e..c50c2f9b 100644
--- a/docs/plugins/api/add-endpoint.md
+++ b/docs/plugins/api/add-endpoint.md
@@ -26,7 +26,10 @@ new Indiekit.addEndpoint(options);
 : A string representing the path to mount routes onto. **Required**.
 
 `navigationItems`
-: A single [`NavigationItem`](#navigationitem) or an array of multiple [`NavigationItem`](#navigationitem)’s used to add items to the web interface’s navigation menu.
+: A single or array of [`NavigationItem`](#navigationitem) objects used to add items to the web interface’s navigation menu.
+
+`shortcutItems`
+: A single or array of [`ShortcutItem`](#shortcutitem) objects used to add [`shortcut` items](https://developer.mozilla.org/en-US/docs/Web/Manifest/shortcuts) to the web app manifest.
 
 ## Methods
 
@@ -65,6 +68,17 @@ Used to register the plug-in. Accepts an `Indiekit` instance to allow its modifi
 
 > A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions.
 
+### `ShortcutItem`
+
+`url`
+: A string representing the path to the page and used for the shortcut’s `url` attribute. **Required**.
+
+`name`
+: A string representing the text shown in the shortcut item. **Required**.
+
+`requiresDatabase`
+: A boolean for whether the item should only be displayed if a database has been configured.
+
 ## Example
 
 ```js
diff --git a/packages/endpoint-files/index.js b/packages/endpoint-files/index.js
index 8eb70f37..f2e68292 100644
--- a/packages/endpoint-files/index.js
+++ b/packages/endpoint-files/index.js
@@ -1,3 +1,4 @@
+import path from "node:path";
 import express from "express";
 import { deleteController } from "./lib/controllers/delete.js";
 import { fileController } from "./lib/controllers/file.js";
@@ -24,6 +25,14 @@ export default class FilesEndpoint {
     };
   }
 
+  get shortcutItems() {
+    return {
+      url: path.join(this.options.mountPath, "upload"),
+      name: "files.upload.action",
+      requiresDatabase: true,
+    };
+  }
+
   get routes() {
     router.get("/", filesController);
 
diff --git a/packages/endpoint-posts/index.js b/packages/endpoint-posts/index.js
index 5deed02f..c6160816 100644
--- a/packages/endpoint-posts/index.js
+++ b/packages/endpoint-posts/index.js
@@ -1,3 +1,4 @@
+import path from "node:path";
 import { ISO_6709_RE, isRequired } from "@indiekit/util";
 import express from "express";
 import { deleteController } from "./lib/controllers/delete.js";
@@ -26,6 +27,14 @@ export default class PostsEndpoint {
     };
   }
 
+  get shortcutItems() {
+    return {
+      url: path.join(this.options.mountPath, "new"),
+      name: "posts.create.action",
+      requiresDatabase: true,
+    };
+  }
+
   get routes() {
     router.get("/", postsController);
 
diff --git a/packages/indiekit/lib/controllers/manifest.js b/packages/indiekit/lib/controllers/manifest.js
index ad472caa..f48de0f3 100644
--- a/packages/indiekit/lib/controllers/manifest.js
+++ b/packages/indiekit/lib/controllers/manifest.js
@@ -17,6 +17,7 @@ export const get = async (request, response) => {
         type: "image/png",
       },
     ],
+    shortcuts: application.shortcuts,
     ...(application.shareEndpoint && {
       share_target: {
         action: application.shareEndpoint,
diff --git a/packages/indiekit/lib/middleware/locals.js b/packages/indiekit/lib/middleware/locals.js
index 44a0181a..4b2e9023 100644
--- a/packages/indiekit/lib/middleware/locals.js
+++ b/packages/indiekit/lib/middleware/locals.js
@@ -1,5 +1,6 @@
 import { getEndpoints } from "../endpoints.js";
 import { getNavigation } from "../navigation.js";
+import { getShortcuts } from "../shortcuts.js";
 import { getUrl } from "../utils.js";
 
 /**
@@ -32,6 +33,9 @@ export const locals = (indiekitConfig) =>
         application.navigation = getNavigation(application, request, response);
       }
 
+      // Application shortcuts
+      application.shortcuts = getShortcuts(application, response);
+
       // Application endpoints
       request.app.locals.application = {
         ...application,
diff --git a/packages/indiekit/lib/shortcuts.js b/packages/indiekit/lib/shortcuts.js
new file mode 100644
index 00000000..8d66d523
--- /dev/null
+++ b/packages/indiekit/lib/shortcuts.js
@@ -0,0 +1,26 @@
+export const getShortcuts = (application, response) => {
+  // Default shortcut items
+  let shortcuts = [];
+
+  // Add shortcut items from endpoint plug-ins
+  for (const endpoint of application.endpoints) {
+    if (endpoint.shortcutItems) {
+      const shortcutItems = Array.isArray(endpoint.shortcutItems)
+        ? endpoint.shortcutItems
+        : [endpoint.shortcutItems];
+      shortcuts = [...shortcutItems, ...shortcuts];
+    }
+  }
+
+  // Remove shortcut items that require a database if no database configured
+  if (!application.hasDatabase) {
+    shortcuts = shortcuts.filter((item) => !item.requiresDatabase);
+  }
+
+  // Translate text strings
+  for (const item of shortcuts) {
+    item.name = response.locals.__(item.name);
+  }
+
+  return shortcuts;
+};


Test patch:
diff --git a/packages/indiekit/test/unit/navigation.js b/packages/indiekit/test/unit/navigation.js
index 70bfdb15..5325bd2d 100644
--- a/packages/indiekit/test/unit/navigation.js
+++ b/packages/indiekit/test/unit/navigation.js
@@ -40,7 +40,7 @@ describe("indiekit/lib/navigation", () => {
     assert.equal(result[1].href, "/session/login");
   });
 
-  it("Removes navigation items that require a database", () => {
+  it("Returns navigation items that require a database", () => {
     const result = getNavigation(
       application,
       { path: "/bar", session: {} },
diff --git a/packages/indiekit/test/unit/shortcuts.js b/packages/indiekit/test/unit/shortcuts.js
new file mode 100644
index 00000000..3a34f50d
--- /dev/null
+++ b/packages/indiekit/test/unit/shortcuts.js
@@ -0,0 +1,39 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import { mockResponse } from "mock-req-res";
+import { getShortcuts } from "../../lib/shortcuts.js";
+
+const application = {
+  hasDatabase: false,
+  installedPlugins: [],
+  locale: "en",
+  endpoints: [
+    {
+      id: "foo",
+      name: "Foo plug-in",
+      shortcutItems: [
+        {
+          url: "/foo",
+          name: "Foo",
+          requiresDatabase: true,
+        },
+        {
+          url: "/bar",
+          name: "Bar",
+        },
+      ],
+    },
+  ],
+};
+const response = mockResponse({
+  locals: { __: (value) => value },
+});
+
+describe("indiekit/lib/shortcuts", () => {
+  it("Returns shortcut items that require a database", () => {
+    const result = getShortcuts(application, response);
+
+    assert.notEqual(result[0].url, "/foo");
+    assert.equal(result[0].url, "/bar");
+  });
+});