最近流行りのraycast、みなさん使ったことがありますか?
シュートカットで便利な機能を複数使うことができて、使用されている方も多いと思います。
Raycastでは自身で拡張機能を作れるということなので、今回はQiitaの記事をタグ検索できる拡張機能を作ってみようと思います。
Raycastとは
Raycastは、MacOS向けのアプリケーションランチャーツールです。
アプリケーションの起動、ファイルの検索、スニペットの管理など、様々な機能をショートカットキー一つで素早く実行できるようにするツールです。
今回作る拡張機能
今回はQiitaAPIを使用して、Qiitaの記事をtagで検索する拡張機能を作成しようと考えました。
Qiitaを書いていると他の記事を読んでもらうために関連記事を書くことが多いのですが、例えばGoの関連記事が20記事以上もあって、それを一々検索してURLをコピーするのはめんどくさすぎます。
なので、一気にタグ検索をかけて検索にひっかかる記事をコピーできるような機能を作ろうと思います。
拡張機能のセットアップ
raycastを開いて、検索バーでCreate Extensionと検索します。
すると作成画面が出てくるので、必要な情報を入力していきます。

今回は、TemplateとしてSubmit Formを使用します。

Locationで指定した位置にプロジェクトが作成されていると思います。
そのプロジェクト位置でnodeパッケージのインストールを行います。
npm install
yarnでも互換性があるかなと思ったのですが、無理だったので後述します。
実際に書いてみる
プロジェクトには、srcファイルの中にtsxファイルが一つだけ配置されている状態になっています。
先ほどTemplateで選択したSubmitFormのおかげである程度Formの原型ができているので、それをベースとして作成しました。
詳しい実装は下のリポジトリにあります。
今回はその中でもraycastで用意されていたhookを使っていくつか実装したりとかしたので、その部分の紹介をしたいと思います。
Form
下のドキュメントを読んでいただければわかりますが、FormにはTextFieldだけでなく、DropDownMenuやTagPikker等も入力フィールドとして準備されています。
今回、バリデーションを行うためにuseFormというHookを使用しています。
useFormは下記の部分で定義しています。
まず、型アノテーションで入力フォームのプロパティとそのデータ型を定義して、useFormのジェネリクスとして設定します。
第一引数のonSubmitでは、入力したい内容をフォームを送信した際にやる処理をに記述します。
第二引数のvalidationでは、入力必須にしたいフォームの入力を指定しています。
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内で下のようなコードを書いてると思います。
await showToast({
    style: Toast.Style.Success, 
    title: "Success Copied!",
});
これはフォームにおけるonSubmitが成功した時に表示しているのですが、その場合にはraycastの左下に以下のような感じで表示されます。
styleでは、緑の丸の部分の表示するのかをいくつか選択することができます。
| style | 表示内容 | 
|---|---|
| Toast.Style.Success | 緑色の円で成功を表す | 
| Toast.Style.Failure | 赤色の縁で失敗を表す | 
| Toast.Style.Animated | ぐるぐると円が回転する、ローディングを表す | 
LocalStrage
Raycastの拡張機能には、ストレージ用のAPIが用意されており、それを使ってデータを暗号化しつつ、永続化できるようにしています。
データの永続化をさせることでキャッシュができるようになるので、今回はそれで入力フォームのキャッシュを行っています。
実際にfunc.tsで以下のようにStorageへの格納取り出しを行っています。
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でデータを格納します。
await LocalStorage.setItem(key, value)
getItemでkeyを指定してvalueを取り出します。
const value = await LocalStorage.getItem(key)
Navigation
Raycastでの拡張機能において、画面遷移を簡単に実装する仕組みとして、Navigationが用意されています。
useNavigationというhookを使うことで、コンポーネントの切り替え表示することができます。
まず下のようにコンポーエントを準備します。
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={``}
              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の引数にコンポーネントを指定して、画面遷移をします。
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を出すようにしました。
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
yarnではなく、npmを使った理由
元々yarnでも動かせるのではないかなと思ったのですが、actions上では下のようにyarnを使ってるのに、npmで実際にどのパッケージのversionを入れたかを管理するpackage-lock.jsonを探してしまうというエラーに遭遇しました。
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出すことをお勧めします。
というかやり方を調べてない
ぜひ自分でも拡張機能を作ってみてください!


