【Vue3 + Firebase】ブラウザでCLIを遊べるポートフォリオを作った

# はじめに
昨年（2020年）、大塚製薬さんの「[CalorieMate to Programmer CUIモード | 大塚製薬](https://www.otsuka.co.jp/cmt/to_programmer/cui/)」というサイトが流行ったかと思います。
当時それを見てなんか面白そうだなと思い、似たようなものを勝手に作ってポートフォリオとして公開してました。
パクリとはいえ割とこだわっており、Lighthouseの評価も全て100点を取りました（軽いので当然ではある）。

そして今年、インターンシップもあるしそろそろTypeScript触るかという気持ちで、このポートフォリオを作り直すことにしました。
この記事では機能と実装を軽く解説したいと思います。

# 作ったもの
サイト：https://cli.taso.tech
GitHub：https://github.com/taso0096/taso-cli

サービス名はそのまま「taso-cli」です。
アイコンは前回のバージョンではフリー素材でしたが、今回は自分で適当に作りました。

まず、本家との大きな違いは下の2つかなと思ってます。

* `/`区切りで深い階層までパス指定できる
* OGPに対応している

他にも細かな機能を追加していたり使いやすいよう工夫したりしてます。
![taso-cli.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303778/4bb57247-af12-475e-cb8d-3dc8ac2db86a.png)

# フロントエンド
Vuetifyが対応するまで触るつもりのなかったVue3とTypeScriptを採用しています。
最初に言った通り、インターンシップの練習のためですね。
ちなみにVuetifyは使わず、適当にCSS書いてます。
別に使っても良いんですけど前回Lighthouseに無駄だと怒られたのでやめました。

# バックエンド
去年[midies](https://midies.app)を作ってからずっと使ってるFirebaseを採用しました。

## Hosting
Vueでビルドしたファイルをホスティングしてます。
キャッシュの設定とOGP用にリダイレクトの設定なんかを行いました。

```json
"hosting": {
  "rewrites": [
    {
      "source": "/",
      "destination": "/index.html"
    },
    {
      "source": "**",
      "function": "getOgpHtml"
    }
  ],
  "headers": [
    {
      "source": "/@(js|css)/**",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "max-age=2592000"
        }
      ]
    }
  ]
}
```

この設定でFunctionsの処理も加わり、リダイレクトはこんな感じで行われます。

```
https://cli.taso.tech/home/taso0096 => https://cli.taso.tech/?path=%2Fhome%2Ftaso0096
```

## Storage
taso-cliでシミュレート？するディレクトリの中身をそのまま保存してます。
また、Functionsで生成したOGP画像も違うバケットに保存してます。

## Authentication
管理ユーザ用です。
ログイン方法の関係でパスワードなしのメールリンクからのみログインできるようにしてます。

## Firestore
管理ユーザの設定とディレクトリ構造のJSONの保存に使ってます。

## Functions
### addAdminClaim / removeAdminClaim
Firestoreの管理ユーザの変更を検知して、ユーザに管理者権限の付与と削除を行います。
実装は[こちら](https://lnly.hatenablog.com/entry/2019/01/25/012744)を参考にしています。

### onUpdateRootDir
Firestoreのディレクトリ構造の変更を検知して、それを基にStorageを探索しOGP画像の生成を行います。
生成した画像はStorageの違うバケットに保存されます。
Functionsでの任意の画像の生成は、色々方法があるかと思いますが今回は（無駄に）puppeteerを使いました。
SVGも頑張ったんですけど改行の処理とかフォントとか面倒だったので諦めました。
無駄とか言いましたがこれしか方法なかった気もします。
OGPの画像は全てのディレクトリとファイルに対しての`ls`、`cat`、`imgcat`などのコマンドの実行結果です。
後から日本語に対応できていないことに気づきましたが、どうしようもなかったのでエラーメッセージを表示するようにだけしました。
下の画像が実際のサンプルになります。

![ogp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303778/4add40c5-3449-64b1-e420-bd52bfaf372d.png)

### getOgpHtml
OGPのHTMLを生成する関数です。
JavaScriptでホスティングファイルにリダイレクトさせてます。
一番最初はデータURLのSVGでの画像配信を考えましたが、OGPではそもそもデータURLに対応してなさそうです...

# さらに解説
ここでは実装についていくつか解説したいと思います。

## taso-cli
そもそもCLIの再現自体はどうやってるのか気になる方もいるかも知れません。
前回は何も考えずにif文とかだけで気合で実装してました。
今回はもう少し頭を使って2つのクラスを作りました。
それでもしっかりした構文解析などは行っていませんでしたが、後半になればなるほどもっとしっかりやればよかったと思いました。
次に作り直す機会があれば、インタプリタみたいな感じで実装しようかと考えてます。

### shellクラス
shellとkernelって名前ではありますが、そんなに詳しくないので単にファイル分割したかったんだなくらいに思ってください。
shellではコマンド入力、コマンド履歴の管理、ファイルシステム？関連などを担当しています。
ファイルシステムはkernelの方が良かった気はしますが今更直しません。
主な動作として、入力されたコマンドをkernelに渡し、その結果を保存する感じです。

### kernelクラス
kernelでは、受け取ったコマンドの解析と実行を担当してます。
あとはクッキーの使用許可もここで処理してたりします。

## コマンド入力
前回からコマンド入力部分は少し工夫していて、下の画像みたいにちゃんと改行ができるようにしてます。
![input.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303778/5768abf8-9237-6c95-821e-3ea48118115f.png)
普通にinputとかtextareaでは恐らくできないと思います（少なくとも簡単には）。
なのでHTMLの[contenteditable](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable)を使って実装しています。
ただ、去年からブラウザの仕様が変わったみたいで修正に無駄に時間を取られてしまいました。
これはまた別で記事を書くかも知れませんが簡単に説明すると、入力されたスペースが連続している場合はHTML内では
普通のスペースとノンブレークスペースが交互に出力されてしまう問題がありました。
最初は原因が全く分からず`input.split(/ +/)`で引数配列を作ろうとしてもスペースが連続してるとできなかったんですね。
試行錯誤して最終的にスペースをコピーして検証してみると、下の結果が出てきて何かがおかしいと気づきましたw

```javascript
s1 = ' ';
s2 = ' ';
s1 === s2; // false
```

それでASCIIコードを調べてみると片方はどうやらノンブレークスペースらしいと気づけました。
対処法としてはノンブレークスペースを普通のスペースに置き換えるだけですね。

```javascript
input.replace(/\u00a0/g, '\u0020');
```

本当にこれで良いかまでは分かりませんが...

## コンポーネント
起動画面用、コマンド入力・入力コマンドの表示用、コマンド結果の表示用の3つのコンポーネントを作成しています。
これをshellクラスの履歴とかを使って表示してる感じです。
ただ1つだけ問題があって、コマンドの実行中に入力を受け付けることができません。
今回はそういったコマンドがないので問題はないですが、次回があればもう少しちゃんとしたいですね。
コマンドの入力と実行はこんな感じです。

```javascript
const cmd = await cmdLineRef.value.input();
await tasoShell.execCmd(cmd);
```

cmdLineRefがコマンドの入力コンポーネントとなっていて、inputメソッドを呼び出してコマンドを受け取り実行しています。
inputメソッドについてはPromiseを返していて、Enterが押されたタイミングでresolveしてる感じです。

## GitHub
使ってみて気づいた方もいると思いますが、`~/repositories/taso-cli`にリポジトリのファイルが入っています。
これはGitHub APIを使って適当に実装してる感じです。
ちゃんとファイルの表示もできます。
ただ1時間に60回までの制限があるので使いすぎにはご注意ください。
（ディレクトリ構造の生成は事前にしてあるのでlsとかする分には問題ありません。）
適当に調べてもよく分からなかったのですが、もしGitHubの規約違反とかなら誰か教えてください。

# その他機能
解説するまでもない機能の紹介です。

## パス連動
taso-cli内でカレントディレクトリを変更するとURLのパスに反映されます。
当然、逆も可能です。

## GETクエリ
ページを開くときにURLを見てれば気付くかも知れませんが、`path`と`cmd`クエリがあります。
pathはFunctionsからリダイレクトされたときにカレントディレクトリを変更するためのものです。
cmdは起動が終わってから最初に実行されるコマンドです。
厳密には2番目に実行されることもあります（パスをディレクトリではなくファイルにしてみると？）。

## パーミッション
実はいくつかのファイルにはパーミッションが設定されており基本的には開けません。
これは管理ユーザでログインしていても同様です（管理ユーザとは）。
なので変にログインを試みたりしないでください...
では、中身を確認する方法がないのかというと、実は1つだけあります。
ここでは答えは教えないので、ぜひ探してみてください。
特に何もありませんが見つけたらTwitterで連絡でもしてください。

## コマンド補完
タブキーとダブルクリック（ダブルタップ）でのコマンド補完をサポートしています。

# 改善点
![lighthouse.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303778/a5406fb7-250c-46fc-3b22-19299c780cb2.png)
![unused.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303778/56241f5e-e8d4-8b32-4dc0-20a5d4f219a2.png)
勘弁して...

# 終わりに
最初はVue3 + TypeScriptの練習として、まあ1週間でいけるだろという気持ちで開発を始めましたが...
なんだかんだで1ヶ月かかってしまいましたね。
研究の中間発表が来月くらいにあった気がしますがまあ気のせいでしょう。

今回初めてSPAでのOGP対応をしましたが、Functions自体ではなく画像の用意が大変なんだなと気づきました。
あとはリダイレクトをどうさせるかってのもサービスによって変わるかと思います。
とはいえ今回もFirebaseだけで完結することができ、改めてFirebaseの便利さを実感できました。
あと使えそうで使ってないのはMessaging APIくらいでしょうか？
いつか使ってみたいと思います。

最後まで読んでいただきありがとうございました。



# 参考文献
[Firebaseドキュメント](https://firebase.google.com/docs)
[Firebase で公開するウェブサイトに「管理者機能」を付ける](https://lnly.hatenablog.com/entry/2019/01/25/012744)
[Cloud FunctionとSVGでOGP画像生成を試行錯誤したまとめ](https://www.memory-lovers.blog/entry/2020/02/19/150000)
[Firebase Admin SDKからStorageにアップロードする](https://qiita.com/hitsuji-haneta/items/a2ee2eee961ec11d0ada)
[Vue.jsとFirebaseでOGP画像生成系のサービスを爆速で作ろう](https://qiita.com/serinuntius/items/3017fb6ef51cd47352f6)