Raycastの拡張機能を作ってみる

最近流行りのraycast、みなさん使ったことがありますか？

シュートカットで便利な機能を複数使うことができて、使用されている方も多いと思います。

Raycastでは自身で拡張機能を作れるということなので、今回はQiitaの記事をタグ検索できる拡張機能を作ってみようと思います。

# Raycastとは

Raycastは、MacOS向けのアプリケーションランチャーツールです。

アプリケーションの起動、ファイルの検索、スニペットの管理など、様々な機能をショートカットキー一つで素早く実行できるようにするツールです。

https://www.raycast.com/

# 今回作る拡張機能

今回はQiitaAPIを使用して、Qiitaの記事をtagで検索する拡張機能を作成しようと考えました。

Qiitaを書いていると他の記事を読んでもらうために関連記事を書くことが多いのですが、例えばGoの関連記事が20記事以上もあって、それを一々検索してURLをコピーするのはめんどくさすぎます。

なので、一気にタグ検索をかけて検索にひっかかる記事をコピーできるような機能を作ろうと思います。

https://qiita.com/api/v2/docs

# 拡張機能のセットアップ

raycastを開いて、検索バーで`Create Extension`と検索します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/a85b9956-eb58-4127-8fbe-d003bfc0d396.png)

すると作成画面が出てくるので、必要な情報を入力していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/2728ef98-a6a6-4abc-bfa9-2497d409d0a3.png)

今回は、Templateとして`Submit Form`を使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/7fbe6940-9691-442c-8ba0-2cf5da9ed851.png)

次の画面が出たら作成完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/c9079e35-eecb-41cc-9615-98c3db7de294.png)

`Location`で指定した位置にプロジェクトが作成されていると思います。

そのプロジェクト位置でnodeパッケージのインストールを行います。
```zsh:zsh
npm install
```
>yarnでも互換性があるかなと思ったのですが、無理だったので後述します。

# 実際に書いてみる

プロジェクトには、srcファイルの中にtsxファイルが一つだけ配置されている状態になっています。

先ほど`Template`で選択したSubmitFormのおかげである程度Formの原型ができているので、それをベースとして作成しました。

詳しい実装は下のリポジトリにあります。

https://github.com/maooz4426/search-qiita-article

今回はその中でもraycastで用意されていたhookを使っていくつか実装したりとかしたので、その部分の紹介をしたいと思います。

## Form
下のドキュメントを読んでいただければわかりますが、FormにはTextFieldだけでなく、DropDownMenuやTagPikker等も入力フィールドとして準備されています。

https://developers.raycast.com/api-reference/user-interface/form

今回、バリデーションを行うために`useForm`というHookを使用しています。

https://developers.raycast.com/utilities/react-hooks/useform

:::note
### バリデーション(validation)
確認や検証を表すことであり、プログラミングでは入力されたデータの値が想定されているデータ型なのかということを調べることを言います。
:::

`useForm`は下記の部分で定義しています。
まず、型アノテーションで入力フォームのプロパティとそのデータ型を定義して、useFormのジェネリクスとして設定します。
第一引数の`onSubmit`では、入力したい内容をフォームを送信した際にやる処理をに記述します。
第二引数の`validation`では、入力必須にしたいフォームの入力を指定しています。
```search-qiita-article-by-tag.tsx
type SearchArticleValues = {
  accessToken: string;
  userID: string;
  tag: string;
};

const { handleSubmit, itemProps, setValue } = useForm<SearchArticleValues>({
    async onSubmit(values) {
      await saveAccessToken(values.accessToken); //ここら辺は後々説明
      await saveUserID(values.userID);
      await saveTag(values.tag);

      const query = `?query=user:${values.userID}+tag:${values.tag}`;
      const urls: string[] = [];
      const articles: ArticleInfo[] = [];

      try {
        await showToast({
          style: Toast.Style.Animated,
          title: "Fetching Article...",
        });

        setAccessToken(values.accessToken);
        const res = await apiClient.get(query);

        res.data.forEach((item: QiitaItemRes) => {
          if (item) {
            articles.push({
              title: item.title,
              url: item.url,
              image: "",
              likes_count: item.likes_count,
              stocks_count: item.stocks_count,
              tags: item.tags,
            });
          }
        });

        const setArticle = await setOgps(articles);

        await showToast({
          style: Toast.Style.Success,
          title: "Success Copied!",
        });

        await push(<ResultView articles={setArticle} urls={urls} />);
      } catch (err) {
        await showToast({
          style: Toast.Style.Failure,
          title: `Fetching Error: ${err}`,
        });
      }
    },
    validation: {
      accessToken: FormValidation.Required,
      userID: FormValidation.Required,
    },
  });
```

## showToast
showToastで、左下にポップアップ表示することができます。

例えば、上のuseFormのonSubmit内で下のようなコードを書いてると思います。
```tsx
await showToast({
    style: Toast.Style.Success, 
    title: "Success Copied!",
});
```

これはフォームにおけるonSubmitが成功した時に表示しているのですが、その場合にはraycastの左下に以下のような感じで表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/7ce7d5d1-d644-4830-964f-890d96849f23.png)

`style`では、緑の丸の部分の表示するのかをいくつか選択することができます。
|style|表示内容
|---|---
| Toast.Style.Success| 緑色の円で成功を表す
|Toast.Style.Failure|赤色の縁で失敗を表す
|Toast.Style.Animated| ぐるぐると円が回転する、ローディングを表す

## LocalStrage
Raycastの拡張機能には、ストレージ用のAPIが用意されており、それを使ってデータを暗号化しつつ、永続化できるようにしています。

https://developers.raycast.com/api-reference/storage

データの永続化をさせることでキャッシュができるようになるので、今回はそれで入力フォームのキャッシュを行っています。

実際に`func.ts`で以下のようにStorageへの格納取り出しを行っています。

```func.ts
import { LocalStorage } from "@raycast/api";

export const saveAccessToken = async (accessToken: string) => {
  await LocalStorage.setItem("accessToken", accessToken);
};

export const getAccessToken = async () => {
  const accessToken = await LocalStorage.getItem<string>("accessToken");
  return accessToken?.toString();
};

export const saveUserID = async (id: string) => {
  await LocalStorage.setItem("userId", id);
};

export const getUserID = async () => {
  const accessToken = await LocalStorage.getItem("userId");
  return accessToken?.toString();
};

export const saveTag = async (tag: string) => {
  await LocalStorage.setItem("tag", tag);
};

export const getTag = async () => {
  const tag = await LocalStorage.getItem("tag");
  return tag?.toString();
};

```

`setItem`でストレージに、`key`と`value`でデータを格納します。

```ts
await LocalStorage.setItem(key, value)
```

`getItem`でkeyを指定してvalueを取り出します。

```ts
const value = await LocalStorage.getItem(key)
```

## Navigation
Raycastでの拡張機能において、画面遷移を簡単に実装する仕組みとして、`Navigation`が用意されています。

https://developers.raycast.com/api-reference/user-interface/navigation#navigation

`useNavigation`というhookを使うことで、コンポーネントの切り替え表示することができます。

まず下のようにコンポーエントを準備します。

```components/ResultView.tsx
import { ActionPanel, Action, List } from "@raycast/api";
import { ArticleInfo } from "../types";

type ResultViewProps = {
  articles: ArticleInfo[];
  urls: string[];
};

export const ResultView = (props: ResultViewProps) => {
  return (
    <List isShowingDetail>
      {props.articles.map((article, i) => (
        <List.Item
          key={i}
          title={`${article.title}`}
          actions={
            <ActionPanel>
              <Action.CopyToClipboard title="All Articles Copy" content={props.urls.join("\n\n")} />
              <Action.CopyToClipboard title={"Copy Article URL"} content={article.url} />
              <Action.OpenInBrowser url={article.url} />
            </ActionPanel>
          }
          detail={
            <List.Item.Detail
              markdown={`![](${article.image})`}
              metadata={
                <List.Item.Detail.Metadata>
                  <List.Item.Detail.Metadata.Label title="title" text={props.articles[i].title} />
                  <List.Item.Detail.Metadata.Label title="url" text={props.articles[i].url} />
                  <List.Item.Detail.Metadata.Label
                    title="likes_count"
                    text={props.articles[i].likes_count.toString()}
                  />
                  <List.Item.Detail.Metadata.Label
                    title="stocks_count"
                    text={props.articles[i].stocks_count.toString()}
                  />
                  <List.Item.Detail.Metadata.Label
                    title="tags"
                    text={props.articles[i].tags.map((tag) => tag.name).join(" ")}
                  />
                </List.Item.Detail.Metadata>
              }
            />
          }
        />
      ))}
    </List>
  );
};

```

そこから画面遷移元でuseNavigationからpush関数を受け取理、pushの引数にコンポーネントを指定して、画面遷移をします。

```search-qiita-article-by-tag.tsx
const { push } = useNavigation();

await push(<ResultView articles={setArticle} urls={urls} />);
```

今回は使用していないですが、useNavigationにはpop関数も用意されており、pop関数では現在コンポーネントを取り除き、前画面に戻ることができます。

|関数|機能
|---|---
|push|新しいコンポーネントを描画(画面遷移)
|pop|現在表示されてるコンポーネントを削除(前画面に戻る)

# 軽くCIを実装する

Raycastの拡張機能には、eslintとprettierでコードの静的解析と整形におけるフォーマットが決められています。

なので、prettierにおけるコードの整形をGithubActionsで自動化し、整形する際は`actions-diff-pr-management`でPRを出すようにしました。

https://qiita.com/maooz4426/items/caf1537e469eff68e3c5

```.github/workflows/lint.yml
name: lint
on:
  pull_request:
    types:
      - opened
      - reopened
      - synchronize
    branches:
      - develop
      - main

jobs:
  lint:
    runs-on: macos-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'
      - name: run install
        run: npm install
      - name: format
        run: npm run fix-lint
      - name: pr-formatter
        uses: dev-hato/actions-diff-pr-management@v1
        with:
            github-token: ${{ secrets.GITHUB_TOKEN }}
            branch-name-prefix: fix-lint
```

:::note
### yarnではなく、npmを使った理由

元々yarnでも動かせるのではないかなと思ったのですが、actions上では下のようにyarnを使ってるのに、npmで実際にどのパッケージのversionを入れたかを管理する`package-lock.json`を探してしまうというエラーに遭遇しました。

```zsh
yarn run v1.22.22
$ ray lint --fix
missing package-lock.json. Run 'npm install' to generate the file
/home/runner/work/raycast-extension-search-qiita-article/raycast-extension-search-qiita-article/package.json
  15:15  warning  Command's title has to be Title Cased. Expected "Search Qiita Article by Tag"
ready  - validate package.json file
error  - validate package-lock.json
ready  - validate extension icons
ready  - run ESLint
ready  - run Prettier 3.4.2
 ›   Error: linting issues found (tip: try re-running with '--fix')
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Process completed with exit code 2.
```

ローカルではこのようなエラーが出なかったのにactionsではこうなった原因はわかりませんが、yarnではなくnpmで動かすことをお勧めします。

:::

# 最後に

拡張機能は、Raycast/ExtensionsにPRを出すことで拡張機能を簡単に世に公開できます。
今回は個人使用なのでそこまではやりませんでしたが、便利な拡張機能ができた際にはPR出すことをお勧めします。
>というかやり方を調べてない

https://github.com/raycast/extensions

ぜひ自分でも拡張機能を作ってみてください!