avatar
screenshot
2024年9月〜

tsch-farm

monaco-editorを使って作った、type-challengesを快適に解くためのWebアプリです。

概要

type-challengesを快適に解くためのWebアプリです。

暇なときにtype-challengesの初級をすべて解いていたのですが、リポジトリから問題を解くのは何度も画面遷移しなければならず、 TypeScript PlaygroundのPluginsは動作が安定しなかったので、快適に解くことができるようなWebアプリを作りました。

難易度ごとの問題セットを解いたり、自分で問題セットを作成、更新、削除できます。 データはすべてローカルストレージに保存しているので、データの内容が変更されたときにはある程度自動でマイグレーションを実行する機能もあります。

使用した技術

  • TypeScript
  • Next.js (Static Exports)
  • React Aria Components
  • Tailwind CSS
  • GitHub Actions
  • Cloudflare Pages

実装の詳細

問題の読み込み

type-challengesの問題は、jsonとしてgitで管理しています。 手書きでファイルをコピーしてくるのではなく、GitHubから問題を読み込むスクリプトを実装して、更新があるたびに手動で実行するようにしています。 GitHub ActionsでCI/CDを行っているので、このときに読み込むこともできるのですが、 ビルド時に実行すると頻繁にGitHub APIが実行される可能性があるのでgitで管理しています。

そのあと、React Server Componentsの中でファイルシステムから問題を読み取り、 Next.jsのビルド時にすべてのデータを埋め込んでいます。

エディター

editor-screenshot

問題を解くために使っているエディターとして、monaco-editorを使用しています。

type-challengesには、型の判定に@type-challenges/utilsというパッケージで定義されている型を使っているので、 どうにかしてこのパッケージを持ってくる必要があります。 そこで、この記事を参考にして、esm.shというCDNでパッケージをリクエストしたときに付与されるx-typescript-typesというヘッダーから型情報のURLを取得して、 monaco-editorに追加しています。

また、monaco-editorにはショートカットを登録する機能があるので、キーボードで次の問題、前の問題に移動できるようにしています。 ショートカットの登録のために使用するKeyCodeKeyModを直接importすると、 enumを使用しているからなのか、ビルドに失敗したり開発サーバーが遅くなるので、コールバックを渡すという回りくどい設計になっています。

紙吹雪を飛ばす

すべての問題のエラーをなくしたときに紙吹雪を舞わせるようにしました。

monaco-editorではMarkerという仕組みによって発生しているエラーを検出することができます。 Markerが更新されたときにイベントハンドラを実行することもできるため、そのなかですべての問題のエラーを追跡しています。 それと合わせて、右側に表示している問題のリストでもエラーの有無がわかりやすいように色を付けています。

紙吹雪を飛ばすためにはreact-confettiというライブラリを使用しています。 設定はよくわからないのですが、パラメーターをいじってそれっぽくしました。

ローカルストレージのマイグレーション

このWebアプリは、すべてのデータがローカルストレージに保存されます。 ローカルストレージに保存するデータのフォーマットが変わると、 以前のフォーマットで保存されている場合に壊れる可能性があるので、自動でマイグレーションする機能を実装しています。 以下のように特定のkeyのストレージのデータを変換するようにしています。

ts
export const config = defineAppConfig({ version: 3, migrationConfig: [ { key: storageKey, migrations: { 1: (d: SchemaV1): SchemaV2 => {/* ... */}, 2: (d: SchemaV2): SchemaV3 => {/* ... */} } } ] }); // 保存されているnversionが1の場合には、1と2のmigrationsが実行される // 保存されているversionが2の場合には、2のmigrationsが実行される migrateLocalStorage(config);

configにもいくつかの制約があるのですが、型レベルで縛るのは難しそうだったので、 defineAppConfigという関数を作って、その中で検証しています。 マイグレーションはVitestでちょっとだけテストを書いてます。