Creating a bookmarking utility with Raycast Extensions and Cosmic
Published: 2023-08-16 19:13:14

---
title: Creating a bookmarking utility with Raycast Extensions and Cosmic
published: true
date: 2023-07-07 09:17:44 UTC
tags: 
canonical_url: https://kejk.tech/thoughts/creating-a-bookmarking-utility-with-raycast-extensions-and-cosmic
---

## Raycast and Cosmic

> Prerequisites: Raycast Extensions only work on macOS, so if you’re on another device then sadly this tutorial isn’t worth your time.

The Cosmic Bookmarks Extension can be found and downloaded from the [Raycast Store](https://www.raycast.com/kemiljk/cosmic-bookmarks).

## What is a Raycast extension?
Raycast Extensions allow you to complete tasks, create lists, browse media and improve your workflow. You can manage all of your Extensions through a set of built-in developer tools, and publish Extensions to the Raycast Store, and share with the world. Today we’ll get you on the path to doing just that (although you won’t be able to publish this exact Extension as it already exists on the store!).

Raycast Extensions use React but compile to native macOS code under the hood. This means you can write them using the web framework you know already, but still get all the benefits of native code.

## What is Cosmic?
Cosmic is a [headless CMS](https://www.cosmicjs.com/headless-cms) that is all about accelerating the development of content-driven applications. Cosmic offers expansive tools like a [JavaScript SDK](https://www.cosmicjs.com/docs/api#install-the-cosmic-java-script-sdk), allowing developers to easily get content into their applications. You can read the [Cosmic Documentation](https://www.cosmicjs.com/docs) to learn how access your content and extend the dashboard functionality.

## How can Raycast and Cosmic be combined to create a website bookmarking utility?
Make sure you have [Raycast](https://www.raycast.com) installed and you’ve got a [Cosmic](https://www.cosmicjs.com) Bucket ready (you can follow along using the [Raycast Bookmarks](https://www.cosmicjs.com/marketplace/templates/raycast-bookmarks) template to have all the necessary Object Types set up, alongside some sample objects).

### Step 1: Set up your Raycast extension
This step involves creating a new Raycast extension and setting up the necessary files and directories. 

Firstly we’ll set up using the Raycast command bar flow.

1. Open the Create Extension command, name your extension "Hello World" and select the "Detail" template. Pick a parent folder in the Location field and press `⌘ ↵` to continue.

![Raycast Setup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n4g4279rkdx5jr2xwzen.png)

2. Now build the extension. Open your terminal, navigate to your extension directory and run `npm install && npm run dev`. Open Raycast, and you'll notice your extension at the top of the root search. Press `↵` to open it.

![Image of Raycast Extension running](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/geqn6q14x411tgixuwnu.png)

---

You can now remove the core code from this initial file and start with a clean slate.

#### Imports
We’ll need to import a few different things to get started. We’ll start with what’s required from the Raycast API. Raycast has very opinionated and strongly typed components and styles for use via their API. It’s imperative you use these as they map to native macOS code when Raycast renders them. But, for ease, you’ll be writing all of this in React.

Start by installing the additional required dependencies `npm i @raycast/utils react`, this ensures that we have access to all the things we need to write good, type-safe code.

```typescript
import {
  Action,
  ActionPanel,
  Icon,
  List,
  getPreferenceValues,
  openExtensionPreferences,
  Toast,
  showToast,
} from "@raycast/api";
import { useFetch } from "@raycast/utils";
import React from "react";
```

Now that we’ve got these pieces in place, we’ll need to integrate with Cosmic.

### Step 2: Integrate Cosmic
In this step, you will integrate Cosmic into your Raycast extension by adding fetch requests and configuring the CMS settings.

We’re going to leverage Raycast’s built in preference storage to manage holding the Bucket slug, and read key. This means we don’t need to create our own local store and we can rely on Apple’s built in keychain encryption.

Start by declaring your Preferences interface. This ensures we get type completion and helps us catch any errors.

```typescript
interface Preferences {
  bucketSlug: string;
  readKey: string;
}
```

Next, to strengthen the type safety of our fetch requests later on, we’ll declare the interface types for our Objects List and our Singular Object. This again means that we’ll get autocompletion and catch typing errors. We can do this because we know what our model needs to look like ahead of time.

```typescript
interface ObjectsList extends React.ComponentPropsWithoutRef<typeof List> {
  objects: [
    {
      id: string,
      type: string,
      title: string,
      slug: string,
      metadata?: {
        snippet: string,
        url: string,
        read: boolean,
      },
    }
  ];
}

interface SingularObject extends React.ComponentPropsWithoutRef<typeof List> {
  object: {
    id: string,
    type: string,
    slug: string,
    title: string,
    metadata: {
      published: string,
      snippet: string,
      url: string,
      read: boolean,
    },
  };
}
```

#### ObjectsList
This Interface is for our full list of Objects. Think of this like our Extension’s representation of the Objects Table in your dashboard. The difference here is that our List will provide preview data in the Raycast UI which comes from the SingularObject.

#### SingularObject
This is the equivalent of viewing the detail of a specific Object in the dashboard, only we’re rendering specific parts of the content in the frontend, much like if you were rendering it on your own website or app.

#### Adding your keys
Now that we’ve done this, we’ll want to actually input these values and use them in our extension. In Cosmic, go into your Dashboard and visit `{project-name} > {bucket-name} > Settings > API access` and get your Bucket Slug and Read Key.

![Cosmic API access](https://www.cosmicjs.com/_next/image?url=https%3A%2F%2Fimgix.cosmicjs.com%2F2d3071a0-effc-11ed-985a-3383d9405b5e-api-keys.png%3Fw%3D800%26dpr%3D2%26format%3Dauto&w=1920&q=75)

> Note, the keys in this example are fake and won’t work.

### Step 3: Fetch your Bookmarks
Now let’s fetch these Bookmarks into our extension and see how it’s looking.

We need to start by declaring the default function expected by Raycast extensions, this is the `Command()` function. Think of this as your `Root()` function that Raycast expects so it can operate.

```typescript
export default function Command() {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();
    const uri = `https://api.cosmicjs.com/v3/buckets/${`bucketSlug`}/objects?{"type":"bookmarks"}&read_key=${`readKey`}&depth=1&props=slug,title`;
    
    const encoded = encodeURIComponent(uri);
    const { data, isLoading } = useFetch<ObjectsList>(encoded);


{/* The rest of our code */}

}
```

Here we’re using the `getPreferencesValues()` method to fetch the bucketSlug and readKey. To actually get these and ensure they’re stored safely, we need to modify the `package.json` file.

In our `package.json` we can include the following JSON to tell Raycast that we want to use the default `Preferences` view when the extension is first loaded (and provide a way to update these in Raycast’s Settings). The beauty here is that we’ve done almost zero work, but got a default interface from Raycast.

You can add this anywhere, but I’d recommend sitting it underneath `devDependencies`.

```json

"preferences": [
    {
      "name": "bucketSlug",
      "title": "Cosmic Bucket Slug",
      "description": "Your Cosmic Bucket Slug",
      "type": "textfield",
      "defaultValue": "",
      "required": true
    },
    {
      "name": "readKey",
      "title": "Cosmic Read Key",
      "description": "Your Cosmic Read Key",
      "type": "password",
      "defaultValue": "",
      "required": true
    },
]
```

### Step 4: Create our UI
Now we’ll actually render the bookmarks we’ve fetched.

Underneath our fetch constants, we’ll add this return statement, this pulls the data from our `useFetch()` hook and enables us to render a safe Objects List as the fetch request expects us to return an ObjectList with valid properties.

```typescript
return (
  <>
    <List isLoading={isLoading} isShowingDetail>
      {data?.objects.map((object) => (
        <List.Item
          key={object.id}
          title={object.title}
          detail={
            <ObjectDetail id={object.id ?? ""} title={object.title ?? ""} />
          }
          actions={
            <Actions
              id={object.id ?? ""}
              title={object.title ?? ""}
              read={object.metadata?.read ?? false}
            />
          }
        />
      ))}
    </List>
  </>
);
```

As you’ll see, the code is very neat because we get to take advantage of the provided components from Raycast. No styling required, and no heavy lifting on our part. We render a `<List>` which has two properties we want to take advantage of; an `isLoading` prop which is just a boolean that ensures we present a loading UI in Raycast if the data is being fetched (and with a lot of bookmarks, this would be likely); and `isShowingDetail` which is another boolean that presents the secondary detail view for us without having to first click on an entry.


![How our Raycast UI will look with a detail pane](https://imgix.cosmicjs.com/d7e8c510-0c4f-11ee-9f4b-4127b50550d7-cosmic-bookmarks-1.png.jpg)

Our `key` and `title` props in the `<List.Item>` are the main object id and object title that each individual Cosmic object has. You’ll rarely see the `id` in the Cosmic interface as it’s only really exposed in the API, but know that each `id` is unique which is handy for our `map()`.

We also have two special props, `detail={}` and `actions={}`, these two props provide a way for us to present what we want our detail view to be and what actions we want available via the `⌘+K` shortcut.

#### Detail

For this, we’ll use a similar approach to our main `ObjectsList` but we’ll be rendering data on a per-Object level.

```typescript

function ObjectDetail(props: { id: string; title: string }) {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();

  const { data, isLoading } = useFetch<SingularObject>(
    `https://api.cosmicjs.com/v3/buckets/${bucketSlug}/objects/${props.id}?read_key=${readKey}&depth=1&props=`
  );

  if (!data?.object) {
    return null; // Return null when data is not available
  }

{/* The rest of our code */}

}
```

Again, we’re fetching the preferences we have stored in the main interface, this is so that we can pass them into our new `useFetch()` hook. In this case, we’re telling Raycast that we want our fetch to match the interface we defined for `SingularObject` for strong type safety and autocomplete (thanks Typescript!).

So now we can actually load our detail view and see what we’ve been looking for! Comment out the lines for now to enable it to render.

```typescript

{/* actions={
  <Actions
    id={object.id ?? ""}
    title={object.title ?? ""}
    read={object.metadata?.read ?? false}
  />
} */}
``` 

In this detail view, we’re using a simple regex command that I wrote about [here](https://kejk.tech/thoughts/strip-your-urls-to-super-simple-domains?ref=cosmic) to trim the domains we show to just the core site name (often known as an `eTLD+1` trim).

```typescript

const regex = /.*https:\/\/www\.|http:\/\/www\.|https:\/\/|http:\/\/|\/.*$/gm;
  const trimmedURL = data?.object.metadata.url.replace(regex, "");

  return (
    data?.object && (
      <List.Item.Detail
        isLoading={isLoading}
        markdown={
          `## ${props.title}` +
          "\n\n" +
          `${data?.object.metadata?.snippet ?? ""}`
        }
        metadata={
          <List.Item.Detail.Metadata>
            <List.Item.Detail.Metadata.Label
              title="Slug"
              text={trimmedURL ?? ""}
            />
            <List.Item.Detail.Metadata.Label
              title="Status"
              text={data?.object.metadata.read ? "Read" : "Unread"}
            />
          </List.Item.Detail.Metadata>
        }
      />
    )
  );
```

#### Actions

Finally, with Actions we’ll be able to easily visit one of our bookmark links or copy it to the clipboard to share elsewhere. We can do a lot of powerful things with Actions, and the full version of this that you can get from the Raycast Store utilises your Cosmic write key to update objects through the `updateOne()` and `deleteOne()` methods. But for this tutorial, we’re focused on read-only.

So again, like with our Detail view, we’ll fetch the same data in the same way (we’re also passing the same props down to it!).

```typescript
function Actions(props: { id: string; title: string }) {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();

  const { data } = useFetch<SingularObject>(
    `https://api.cosmicjs.com/v3/buckets/${bucketSlug}/objects/${props.id}?read_key=${readKey}&depth=1&props=`
  );

  if (!data?.object || data === null) {
    return null; // Return null when data is not available
  }

{/* The rest of our code */}

}

```

In order to render these actions in the UI, we again have super helpful components from Raycast.

```typescript

return (
    <ActionPanel title={props.title}>
      <ActionPanel.Section>
        {data?.object && (
          <Action.OpenInBrowser
            url={data?.object.metadata?.url}
          />
        )}
        {data?.object.slug && (
          <Action.CopyToClipboard
            content={data?.object.metadata?.url}
            title="Copy Link"
            shortcut={{ modifiers: ["cmd"], key: "." }}
          />
        )}
      </ActionPanel.Section>
      <ActionPanel.Section>
        <Action
          title="Open Extension Preferences"
          icon={Icon.Gear}
          onAction={openExtensionPreferences}
        />
      </ActionPanel.Section>
    </ActionPanel>
  );
}

```

Notice that for `Action.OpenInBrowser` we don’t have to declare a keyboard shortcut or define a specific browser, Raycast handles all that for us by default the main action to `↵`. You can override this by copying the `shortcut={}` code that we have in the `Action.CopyToClipboard`. Just note that modifiers is an array of specific options, so make sure you check the [docs](https://developers.raycast.com/api-reference/keyboard#properties).

### Step 5: Putting it all together
Here’s the full code as one nice big chunk.

```typescript

import {
  Action,
  ActionPanel,
  Icon,
  List,
  getPreferenceValues,
  openExtensionPreferences,
  Toast,
  showToast,
} from "@raycast/api";
import { useFetch } from "@raycast/utils";
import React from "react";

interface Preferences {
  bucketSlug: string;
  readKey: string;
  writeKey: string;
}

interface ObjectsList extends React.ComponentPropsWithoutRef<typeof List> {
  objects: [
    {
      id: string;
      type: string;
      title: string;
      slug: string;
      metadata?: {
        snippet: string;
        url: string;
        read: boolean;
      };
    }
  ];
}

interface SingularObject extends React.ComponentPropsWithoutRef<typeof List> {
  object: {
    id: string;
    type: string;
    slug: string;
    title: string;
    metadata: {
      published: string;
      snippet: string;
      url: string;
      read: boolean;
    };
  };
}

export default function Command() {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();
  const { data, isLoading } = useFetch<ObjectsList>(
    `https://api.cosmicjs.com/v3/buckets/${bucketSlug}/objects?query=%7B%22type%22:%22bookmarks%22%7D&read_key=${readKey}&depth=1&props=`
  );

  return (
    <>
      <List isLoading={isLoading} isShowingDetail>
        {data?.objects.map((object) => (
          <List.Item
            key={object.id}
            title={object.title}
            detail={
              <ObjectDetail id={object.id ?? ""} title={object.title ?? ""} />
            }
            actions={
              <Actions
                id={object.id ?? ""}
                title={object.title ?? ""}
                read={object.metadata?.read ?? false}
              />
            }
          />
        ))}
      </List>
    </>
  );
}

function ObjectDetail(props: { id: string; title: string }) {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();

  const { data, isLoading } = useFetch<SingularObject>(
    `https://api.cosmicjs.com/v3/buckets/${bucketSlug}/objects/${props.id}?read_key=${readKey}&depth=1&props=`
  );

  if (!data?.object) {
    return null; // Return null when data is not available
  }

  const regex = /.*https:\/\/www\.|http:\/\/www\.|https:\/\/|http:\/\/|\/.*$/gm;
  const trimmedURL = data?.object.metadata.url.replace(regex, "");

  return (
    data?.object && (
      <List.Item.Detail
        isLoading={isLoading}
        markdown={
          `## ${props.title}` +
          "\n\n" +
          `${data?.object.metadata?.snippet ?? ""}`
        }
        metadata={
          <List.Item.Detail.Metadata>
            <List.Item.Detail.Metadata.Label
              title="Slug"
              text={trimmedURL ?? ""}
            />
            <List.Item.Detail.Metadata.Label
              title="Status"
              text={data?.object.metadata.read ? "Read" : "Unread"}
            />
          </List.Item.Detail.Metadata>
        }
      />
    )
  );
}

function Actions(props: { id: string; title: string }) {
  const { bucketSlug, readKey } = getPreferenceValues<Preferences>();

  const { data } = useFetch<SingularObject>(
    `https://api.cosmicjs.com/v3/buckets/${bucketSlug}/objects/${props.id}?read_key=${readKey}&depth=1&props=`
  );

  if (!data?.object || data === null) {
    return null; // Return null when data is not available
  }

  return (
    <ActionPanel title={props.title}>
      <ActionPanel.Section>
        {data?.object && (
          <Action.OpenInBrowser
            url={data?.object.metadata?.url}
          />
        )}
        {data?.object.slug && (
          <Action.CopyToClipboard
            content={data?.object.metadata?.url}
            title="Copy Link"
            shortcut={{ modifiers: ["cmd"], key: "." }}
          />
        )}
      </ActionPanel.Section>
      <ActionPanel.Section>
        <Action
          title="Open Extension Preferences"
          icon={Icon.Gear}
          onAction={openExtensionPreferences}
        />
      </ActionPanel.Section>
    </ActionPanel>
  );
}
```

## Conclusion
> Note: The initial Raycast Extension setup provided here is lifted from their [Getting Started](https://developers.raycast.com/) docs.
> You can also find out more about using Cosmic via the [Cosmic Docs](https://www.cosmicjs.com/docs).

Now you should have a good understanding of Raycast extensions and Cosmic, and a good idea of how to leverage them both to make your own custom extensions. If you end up building anything awesome using both Raycast and Cosmic, [share it with us on Twitter](https://twitter.com/CosmicJS)!


Resources:
- [Raycast Bookmarks Template on Cosmic](https://www.cosmicjs.com/marketplace/templates/raycast-bookmarks)
- [Cosmic Bookmarks Extension on the Raycast Store](https://www.raycast.com/kemiljk/cosmic-bookmarks)