のらねこの気まま暮らし

技術系だけど、Qiita向きではないポエムとかを書くめったに更新されないヤツ

Hammerspoon + BetterDisplay CLIでMacのモニターレイアウトを自動復元する

PBP(Picture by Picture)を切り替えるたびにモニターの配置がリセットされて困っていた。毎回手動で直すのが面倒だったので、自動復元する仕組みを作った。

概要

  • Hammerspoonでディスプレイ構成の変更を検知
  • BetterDisplay CLIで希望のレイアウトに復元
  • ホットキーで手動復元も可能

背景

自宅の作業環境では49インチのウルトラワイドモニターを2枚、縦スタック(上下配置)で使っている。PBPで別のPCに切り替えて戻すと、macOSがモニターを再認識する際にレイアウトが横並びにリセットされてしまう。

システム設定からドラッグして直せばいいのだけど、これが地味に面倒。特にPBPを頻繁に切り替える運用だと、1日に何度もやることになる。

必要なもの

BetterDisplayは既に導入済みだったので、CLIとHammerspoonを追加でインストールした。

セットアップ

BetterDisplay CLIのインストール

brew install waydabber/betterdisplay/betterdisplaycli

ビルドにXcode(フルバージョン)が必要。Command Line Toolsだけでは動かない。Xcodeをインストールした後、ライセンスに同意する必要がある。

sudo xcodebuild -license accept

Hammerspoonのインストール

brew install --cask hammerspoon

BetterDisplayの設定

BetterDisplayアプリの Settings > Integration で「Command line integration」を有効にする。これをやらないとCLIが動かない。

ディスプレイ情報の取得

まず希望のレイアウトに手動で設定してから、ディスプレイの識別情報を取得する。

betterdisplaycli get -identifiers

自分の環境だとこんな感じだった。

{
  "name" : "MPG491CX OLED",
  "tagID" : "2",
  ...
},
{
  "name" : "49C1R",
  "tagID" : "3",
  ...
}

Hammerspoon設定

~/.hammerspoon/init.lua を作成する。

-- ディスプレイレイアウト自動復元
-- PBP切り替え等でディスプレイ構成が変わった際に、希望のレイアウトに復元する

-- BetterDisplay CLIのパス

local BETTERDISPLAY_CLI = "/opt/homebrew/bin/betterdisplaycli"

-- 復元までの遅延(秒)
local RESTORE_DELAY = 2

-- ディスプレイ設定
-- 下のディスプレイ(基準)
local BOTTOM_DISPLAY = "MPG491CX OLED"
-- 上のディスプレイ
local TOP_DISPLAY = "49C1R"

-- ディスプレイレイアウトを復元する関数
local function restoreDisplayLayout()
  -- 少し待ってからレイアウト復元(OSが安定するまで)
  hs.timer.doAfter(RESTORE_DELAY, function()
    -- 49C1RをMPG491CX OLEDの上に配置
    local cmd = string.format(
      "%s set --name '%s' --moveTo top --targetName '%s'",
      BETTERDISPLAY_CLI,
      TOP_DISPLAY,
      BOTTOM_DISPLAY
    )

    local output, status = hs.execute(cmd)

    if status then
      hs.alert.show("ディスプレイレイアウトを復元しました")
    else
      hs.alert.show("レイアウト復元に失敗: " .. (output or ""))
    end
  end)
end

-- ディスプレイ構成変更を監視
screenWatcher = hs.screen.watcher.new(function()
  hs.alert.show("ディスプレイ構成が変更されました")
  restoreDisplayLayout()
end)
screenWatcher:start()

-- 手動復元用のホットキー(Ctrl+Cmd+L)
hs.hotkey.bind({"ctrl", "cmd"}, "L", function()
  restoreDisplayLayout()
end)

-- 設定リロード用のホットキー(Ctrl+Cmd+R)
hs.hotkey.bind({"ctrl", "cmd"}, "R", function()
  hs.reload()
end)

-- 起動メッセージ
hs.alert.show("Hammerspoon設定をロードしました")

ポイントは RESTORE_DELAY = 2 の部分。ディスプレイ構成が変わった直後はmacOSがまだ認識中で、すぐにコマンドを実行しても失敗することがある。2秒くらい待つと安定する。

起動

Hammerspoon.appを起動すると、メニューバーにアイコンが表示される。設定を変更した場合は「Reload Config」で再読み込みできる。Ctrl+Cmd+R でも同じことができる。

使い方

  • 自動復元: PBPを切り替えると「ディスプレイ構成が変更されました」→ 2秒後に「ディスプレイレイアウトを復元しました」と表示される
  • 手動復元: Ctrl+Cmd+L で即座に復元

あとがき

BetterDisplay CLIの存在を知らなかったので、最初はdisplayplacerを使おうかと思っていた。BetterDisplayを既に使っているなら、CLIを追加するだけで連携できるので楽。

Hammerspoonは今回初めて使ったけど、Luaでサクッと書けるのがいい。ディスプレイ以外にもウィンドウ管理やキーボードショートカットなど、いろいろできるらしい。しばらく使 ってみて、便利な使い方があれば追記するかもしれない。

Hammerspoon + BetterDisplay CLIでMacのモニターレイアウトを自動復元する

PBP(Picture by Picture)を切り替えるたびにモニターの配置がリセットされて困っていた。毎回手動で直すのが面倒だったので、自動復元する仕組みを作った。

概要

  • Hammerspoonでディスプレイ構成の変更を検知
  • BetterDisplay CLIで希望のレイアウトに復元
  • ホットキーで手動復元も可能

背景

自宅の作業環境では49インチのウルトラワイドモニターを2枚、縦スタック(上下配置)で使っている。PBPで別のPCに切り替えて戻すと、macOSがモニターを再認識する際にレイアウトが横並びにリセットされてしまう。

システム設定からドラッグして直せばいいのだけど、これが地味に面倒。特にPBPを頻繁に切り替える運用だと、1日に何度もやることになる。

必要なもの

BetterDisplayは既に導入済みだったので、CLIとHammerspoonを追加でインストールした。

セットアップ

BetterDisplay CLIのインストール

brew install waydabber/betterdisplay/betterdisplaycli

ビルドにXcode(フルバージョン)が必要。Command Line Toolsだけでは動かない。Xcodeをインストールした後、ライセンスに同意する必要がある。

sudo xcodebuild -license accept

Hammerspoonのインストール

brew install --cask hammerspoon

BetterDisplayの設定

BetterDisplayアプリの Settings > Integration で「Command line integration」を有効にする。これをやらないとCLIが動かない。

ディスプレイ情報の取得

まず希望のレイアウトに手動で設定してから、ディスプレイの識別情報を取得する。

betterdisplaycli get -identifiers

自分の環境だとこんな感じだった。

{
  "name" : "MPG491CX OLED",
  "tagID" : "2",
  ...
},
{
  "name" : "49C1R",
  "tagID" : "3",
  ...
}

Hammerspoon設定

~/.hammerspoon/init.lua を作成する。

-- ディスプレイレイアウト自動復元
-- PBP切り替え等でディスプレイ構成が変わった際に、希望のレイアウトに復元する

-- BetterDisplay CLIのパス

local BETTERDISPLAY_CLI = "/opt/homebrew/bin/betterdisplaycli"

-- 復元までの遅延(秒)
local RESTORE_DELAY = 2

-- ディスプレイ設定
-- 下のディスプレイ(基準)
local BOTTOM_DISPLAY = "MPG491CX OLED"
-- 上のディスプレイ
local TOP_DISPLAY = "49C1R"

-- ディスプレイレイアウトを復元する関数
local function restoreDisplayLayout()
  -- 少し待ってからレイアウト復元(OSが安定するまで)
  hs.timer.doAfter(RESTORE_DELAY, function()
    -- 49C1RをMPG491CX OLEDの上に配置
    local cmd = string.format(
      "%s set --name '%s' --moveTo top --targetName '%s'",
      BETTERDISPLAY_CLI,
      TOP_DISPLAY,
      BOTTOM_DISPLAY
    )

    local output, status = hs.execute(cmd)

    if status then
      hs.alert.show("ディスプレイレイアウトを復元しました")
    else
      hs.alert.show("レイアウト復元に失敗: " .. (output or ""))
    end
  end)
end

-- ディスプレイ構成変更を監視
screenWatcher = hs.screen.watcher.new(function()
  hs.alert.show("ディスプレイ構成が変更されました")
  restoreDisplayLayout()
end)
screenWatcher:start()

-- 手動復元用のホットキー(Ctrl+Cmd+L)
hs.hotkey.bind({"ctrl", "cmd"}, "L", function()
  restoreDisplayLayout()
end)

-- 設定リロード用のホットキー(Ctrl+Cmd+R)
hs.hotkey.bind({"ctrl", "cmd"}, "R", function()
  hs.reload()
end)

-- 起動メッセージ
hs.alert.show("Hammerspoon設定をロードしました")

ポイントは RESTORE_DELAY = 2 の部分。ディスプレイ構成が変わった直後はmacOSがまだ認識中で、すぐにコマンドを実行しても失敗することがある。2秒くらい待つと安定する。

起動

Hammerspoon.appを起動すると、メニューバーにアイコンが表示される。設定を変更した場合は「Reload Config」で再読み込みできる。Ctrl+Cmd+R でも同じことができる。

使い方

  • 自動復元: PBPを切り替えると「ディスプレイ構成が変更されました」→ 2秒後に「ディスプレイレイアウトを復元しました」と表示される
  • 手動復元: Ctrl+Cmd+L で即座に復元

あとがき

BetterDisplay CLIの存在を知らなかったので、最初はdisplayplacerを使おうかと思っていた。BetterDisplayを既に使っているなら、CLIを追加するだけで連携できるので楽。

Hammerspoonは今回初めて使ったけど、Luaでサクッと書けるのがいい。ディスプレイ以外にもウィンドウ管理やキーボードショートカットなど、いろいろできるらしい。しばらく使 ってみて、便利な使い方があれば追記するかもしれない。

zellij(ゼリージュ)の設定メモ

わすれるので

概要

先日導入したけど、細かいことを結構忘れるのでメモを残しておきたい。

設定ファイル

以下のファイルに保存されいてる

./config/zellij/config.kdl

設定ファイルの書き出し

現在の設定を書き出してくれる。便利

zellij setup --dump-config > ~/dotfiles/zeliij/config.kdl

スクロール

Ctrl + s でsearchモードに入る 上下キーでバッファを動かせる

マウスモードを有効にしてれば何もしなくてもスクロールしてくれる。

コピーペースト

copy_commandはosxなのでpbcopyを有効にする。 copy_on_selectはデフォルトのままtrue

copy_command "pbcopy"
copy_on_select true

ubuntu

curl -L -O https://github.com/zellij-org/zellij/releases/download/v0.43.1/zellij-no-web-x86_64-unknown-linux-musl.tar.gz
tar -xzf zellij-no-web-x86_64-unknown-linux-musl.tar.gz -C /usr/local/bin
zellij setup --dump-config > ~/.config/zellij/config.kdl

xselをインストール

sudo apt install xsel -y

zellijのcopy_commandにxselを設定 ```~/.config/zellij/config.kgl copy_command "xsel --clipboard --input"

iPhoneからMac Book Pro上で実行しているclaude-codeに指示を出す

忘れそうなので備忘録。

概要

背景

何となくやりかけだったり、claudeに依頼してそのままでかけることがままある。 返答待ちで時間かかっちゃうのはもったいなっていうのと、特段頭を使うでもないささいな修正をさくっとやっといてほしいなと思った。

調べてみると人気な構成がこれっぽかったので試しにやってみた。

MacOSの共有設定

一般 > 共有 > リモートログイン > ON

MacOS > 一般 > 共有 > リモートログイン

スイッチの隣にある(!)を開いて、アクセスを許可するユーザーを追加。

私は一般ユーザーを追加した。 厳密にやるならアカウントわけたりとかもあるんだけど、session共有のあれこれの手間を増やしたくなかった。

Tailscaleのインストール

tailscale.com

VPNかーと思いつつ、不安になったら権限絞ったり端末わけたりやりようはあるかと思ってえいや。

とても分かりやすいオンボーディングで特に説明することがない。

  1. MacOSでアカウントのセットアップ
  2. MacOSでクライアントインストール・権限の許可
  3. iPhone(Android)でクライアントのインストール
  4. iPhone(Android)でログイン
  5. iPhone(Andoird)からMacOSに接続

Zellijのインストール

zellij.dev

tmuxよりZellij(ゼリージュって読むらしい)の方が流行らしいとXで知った。 Rust製で軽量、どう操作するればいいかの案内が充実していて使いやすい。

久々にtmux使おうとするとコマンドわかんなくて詰むのはよくあるのでこれはありがたい。

brewでinstallできる。

brew install zellij

セッションを開始

zellij

iPhone(Android)にTermiusをインストール

termius.com

iPhoneだとa-Shellなどが無料で利用できるが、Androidからも使いたいのとTermiusが調べた感じ感触よさそうだったので年間契約。 14日のトライアルがあるので不安なら試すだけもできる。

  1. インストール
  2. アカウントのセットアップ
  3. New Host を選択
  4. Labelに任意の接続先名を入力
  5. IP or Hostname に tailscaleの接続先からMagicDNSかIPアドレスをコピーしてきて入力
  6. Username・Password に MaxOSのログインユーザ・パスワードを入力

いったんこれで接続を開始。 コマンドラインシェルが開けて、ホームディレクトリにいることが確認できる。

iPhoneからzellijのセッションに参加

termiusのCLIからzellijのセッションを探す

> zellij list-sessions
loyal-apple [Created 15m 8s ago]

セッションの一覧が表示されるので、接続したいセッションを選択してアタッチする。

> zellij attach loyal-apple

MacOSで開始したclaude-codeのスレッドに合流できる。

あとがき

思ったより簡単にできた。かつてVPSでsshしてiPadから障害対応とか考えた時代が懐かしい。

個人的にはVibe codingとかAI Agent Codingにたいしては懐疑的なスタンスで触ってはいるんだけど、 部分を選べばとても便利で優秀なソリューションだと思っている。

ダメ元でもプロトタイピングさせてみることで気付きを得られる、FBサイクルを早く回せるようになる。 これだけでも何らかの価値はあると思うので、この1年くらいはしっかり付き合ってみようと思っている。

株式会社ディー・エヌ・エーを退職しました。

5月末を持って株式会社ディー・エヌ・エーを退職しました。

 

 

2022/05から3年と数日の在籍でしたが、多くの方と関わり、幅広い仕事をできたことに感謝しています。

 

入社してから新規案件の開発に携わり、当時はまだあまりなの知れてなかったRemixを導入したり、サマーインターンの採用と受け入れに関わり、組織づくりをしたり、新卒採用したり、とにかく広くいろんなことをかなり好き勝手やらせてもらえました。

組織というものを相手にすることについては余白なく実力を発揮したと思いますし、同時に自身の限界を痛感することもありました。

関わってくれた、関わらざるを得なかったみなさんに、様々な経験をいただき感謝しています。あざす。

 

さて、退職ともなれば次の話なるわけですが、転職の予定はありません。

しばらくはフリーランスとしていくつかの会社から案件をいただきながら生活していこうと思っています。

 

フリーランスを選んだ背景は色々ありますが、大雑把に言うとこういうことです。

 

「組織を見る動きに自身の天井が見えたこと、およびその先に望んだ世界がないこと」

 

端的に言えば、無理をしない範囲で継続的に仕事として動くことはできるけど、自分にとってそれは停滞だし、無理を強いて背伸びをしたところで、ほしいものは得られないなと。それであれば、技術者として探求・貢献に人生を割いたほうが楽しめそうだなと感じたためです。

ーーまあそうなるかはともかく、試すにはちょうどいいタイミングだなというのもありました。

 

実際すでにいくつかお話をいただけており、しばらく暇のない生活が続きそうです。感謝しかない。

 

しばらく新しい案件はいっかなーと思ってますが、近況聞きたいとか相談だけでもって方は声かけてください。飲みにいくのはウェルカムだ。酒は飲めんけど。

 

そんなわけで、引き続きよろしくお願いします。

 

 

logone-jsにパスワードのマスク処理を追加した

logone-jsのアップデートを行ったのでブログとして残す。

logoneについては過去の記事を参照してね。 mizuki-r.hatenablog.com

リリース内容

新機能

  • シークレット文字列のマスク機能

不具合修正

  • maxmum call stackの修正

シークレット文字列のマスク機能

開発環境下でのデバッグログや、API通信のペイロードなどをログとして出力したいことは多くある。その中に認証情報やパスワードなどが含まれてしまうこともよくある。

次のようなコードは頻出パターンと言える。

try {
  const data = await axios.post('/authorize', { username, password })
catch (error) {
  logger.error(error.message, { error })
  throw error
}

この時、errorがrequestやresponseなどの情報構造を内包している場合(AxiosErrorとか)、意図せずデータがログに露出する可能性がある。 開発環境のみで動作するものであれば良いが、開発環境フラグを設定し忘れたり、エラーログの中に紛れこんで困ることがあるかもしれない。

RailsやLaravelといった大手のフレームワークで用いるロガーにはそのようなシークレットトークンに対してマスクするような機能が内包されていたりする。

今回logoneに追加したのは任意キーワードにマスクをかけられるようにする機能だ。

以下のような設定を行う。

const app = express()

app.use(logone({
  maskKeywords: [
    'password', // "password"というプロパティに一致する値をマスクする
    /password$/i, // 正規表現で大文字小文字を識別せずpasswordという文字列に後方一致する値をマスクする
    /password:¥s"(.+)"$/, // 'password: "XXXXXXX"' という文字列を検索し、'XXXXXXX'をマスクする
  ]
}))


app.post('/login', (req, res) => {
  req.logger.debug("request", { req })
})

文字列、正規表現のリストをパラメータとして受け取り、それにマッチするプロパティの値を同じ文字列長だけ*でマスクします。プロパティはpayloadのオブジェクトを再起的に探索します。messageに指定された値はmaskされません(が、まあログのメッセージにシークレットを書き出すような運用はしないでしょう...)

また、正規表現がキャプチャグループを持つとき、それはプロパティではなく値そのものから対象を検索します。 これは、HTTP通信のログなどで値がシリアライズされている場合もあるためこのようにしています。

マスク処理自体は単純なリプレイスです。

value.replace(/./g, '*')

maximum call stackの修正

前述の仕組みはpayloadというunkownなオブジェクトに対しての再起探索が必要になります。

今まさにlogoneを利用しているプロジェクトではHTTP通信にAxiosを利用しており、そのエラーオブジェクトは再帰的なオブジェクト構造を持ちます。

再帰構造を持つオブジェクトに再帰処理を行うと Maximum call stack exceededの例外が発生します。 まあ... 処理が終わらないので...

それ故に、JSONに置き換える処理では再帰参照を止めるように仕組みを作っていたんですが、それはAdapter側の出力の範囲でした。今回の対応はAdapterに渡す前のオブジェクトだったため、オブジェクト構造を判定して再帰構造を解体するようにしました。

まとめ

以下の2点を@logone/coreに追加したv0.0.4をリリースしました。 - 再帰的なオブジェクト探索によるパスワード文字列のマスク - 再帰構造を持つオブジェクトの解体

今回は必要に迫られて突貫で対応しましたが、パラメータ名などもうちょっと考慮の余地あったんじゃないかな〜と思ったり。

次は出力できるLogLevelの設定とかを追加したいですね。

宣言的にFormのルールを定義できるdeclaformというライブラリを書いている

まえがき

2年ほど前から実務でRemixを利用している。 使い始めた当時はライブラリも情報もなく、かなり手探りで始めた開発だった。

当時はプロダクトのMVP検証を目的として、手数少なく動くものが作れるという点でRemixを採用した。 実際に使ってみて、その期待は間違ってなかったと感じたし、今も結構手に馴染んでいる。−−不満はあれど。

そんな中でFormのバリデーションについては、あまり深く考えずにRemix Validated Formを採用していた。

当時は選択肢が多くなく、Remix専用となるとこれくらい。 あとは、React Hook Formを使っていくのが無難だっただろう。

最近だとConformなんてのもできており、Form周りのライブラリは何年立っても賑わうものだなぁと感心する。

そんな渦中に一石を投じるほどの意欲はないが、それでもどうしても物申したかったのでゴールデンウィーク返上で筆を取ることにした。

React界隈におけるForm Validationの課題

−−言えるほど偉い立場ではないんだけども。 −−言うほどReact使ってサービス作ってないんだけども。

Formという箱の中にしか書けないルール

例えば、React Hook Formを使かおうと思うとこういうコードになる。

export default function App() {
  const { register, handleSubmit } = useForm<{ username: string, password: string }>()
  
  const onSubmit = (data) => console.log('submit', data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username", { required: true })} />
      <input {...register("password", { required: true })} />
      <button type="submit">Login</button> 
    </form>
  )
}

この場合、useFormを実行することが前提となるので、これらの子となるinput要素はこのuseFormの結果を子まで伝搬させる必要がある。 Componentを切ろうとしても、PropsにReactHookFormの子であることを示す値を設定する必要が出てくる。

また、正しくuseFormしてない箇所に記述はできない。 任意のFormの下でしか動作しないInput要素ということになる。

これは依存性が逆転していると感じている。

Remix Validated Formの場合はこう

import { Form } from 'remix-validated-form'
import { withZod } from '@remix-validated-form/with-zod

const validator = z.object({
  username: z.string(),
  password: z.string(),
})

export default function App() {
  const { register, handleSubmit } = useForm<{ username: string, password: string }>()
  
  const onSubmit = (data) => console.log('submit', data)

  return (
    <Form onSubmit={onSubmit} validator={withZod(validator)}>
      <input type="text" name="username" />
      <input type="password" name="password" />
      <button type="submit">Login</button> 
    </Form>
  )
}

これも似たような、あるいは同様の問題を孕むことになる。 RemixValidatedFormにおいてはvalidationが完全に上位のProviderに依存するので完全に暗黙的な依存を持つことになる。

useFormFieldフックをPresentationalなComponentで呼び出すことになる。そしてStorybookで死ぬ。

これらの構造的制約によって、アプリケーションの開発の幅を狭めざるを得ないことが増えていく。 例えば、Input要素はForm要素の子でなければならない、などの制約がDOM上に露出していくことになるわけだ。それを避けるためには開発者ががんばって頭を捻る必要がある。

とてもつらい。

Schema based validationという外側の制約

React Hook Formは特段使わなければ気にならないかもしれない。

少なくとも、Schema basedなバリデーションを実施しているFormライブラリは最上位にSchemaを食わせる必要がある。

これはInputのnameとruleが外側の何某かによって成約されると言え、要素単位でバラして再利用することをあまり考えていない。 特に業務システムのような複雑な入力フォームを無数に持つ画面の開発において、Form単位でしか共通化できないというのはかなりナンセンスといえる。

重ねて課題を述べるなら、RemixにおいてはForm=Actionの単位なので、無数に一画面にFormにいれたくない。 ある画面では3つですむが、ある画面では5まで関連する要素が増えるみたいなときの共通化を少しは考えさせてほしい。

そのschema、他で使う?問題

最初のころはFormとServerでのバリデーションを共通のSchemaを利用して記述していた。 まあそのままサーバーに送られてくるんだし、問題ないよね。

−−そんなわけなかった。

クライアントとサーバーのバリデーションは意図が異なる。

クライアントでは即座にユーザに問題をフィードバックすることで体験を改善することができる。 パスワードの再入力なんかはは良い例で、クライアントでは必要だけど、別にサーバーでチェックすることはない。

サーバーでのバリデーションは防御的な側面がある。これ入力されちゃマズい、を弾くためだ。 別に処理に関係ないならそもそも使わないしね。

そして、画面を埋めつくするようなフォームを設計する場合、そのフォームはフラットに一つのSchemaなのではなく、複数のSchemaを組み合わせて使うことになる。 つまり、そこにモデルが存在している。

それらを総合的に考えていくと、結局Client Formに向けた専用のSchemaをForm毎に作ることになる。FormのComponentとは別に。

つまり、ただただ関心を分離させているだけと言える。

宣言的ではない

React Hook FormやConformを見るに、hooksでいい感じにパラメータをうめこみますよ〜という手続き的な記述を多用する。 わかるんだよね、便利だし、型制約効くし。

でもそれじゃなんのためにJSXでMarkupしてんのかわからんだろがいという気持ちになる。

ルールを宣言させてくれ。

宣言的にFormを書きたい

色々懸念や課題や愚痴を並べてはいるけど、解決したいのはこれ。 書き心地よくサクッと実装したい。

なので、今回目指したのは「宣言的にかけるForm Validation」

github.com

使い方

  1. バリデーションルールを定義する
  2. フォームを定義する

バリデーションルールの定義

Form単位ではなく、Input(入力)単位でルールを記述する。 ZodとかYup使ってもいいし、使わなくてもいい。

import { defineValidationRule } from '@declaform/core'

const usernameSchema = z.string().min(1).max(64)
defineValidationRule('username', {
  validate(value) {
    return usernameSchema.safeParse(value)
  }
})

const passwordSchema = z.string().min(8).max(64).regex(passwordRegex)
defineValidationRule('password', {
  validate(value) {
    return passwordSchema.safeParse(value)
  }
})
defineValidationRule('passwordCOnfirm': {
  validate(value, data, { ruleReferenceInputName }) {
    return passwordSchema
      .refine(() => {
        return value === data[ruleReferenceInputName]
      }, {
        message: 'Password mismatch'
      })
      .safeParse(value)
  }
})

Formを記述する

単純なケースではあるけど、前提として何らかのComponentにそれぞれのInputやFieldを隠蔽することを想定している。 そのため、カスタマイズが効くようにrenderPropsで受け渡しできるようになっている。

import { HTMLFormElement } from 'react'
import { FormProvider, FormInput } from '@declaform/react'

const App = () => {
  const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
    // 型の整合性に責任は負わないので、使う側で精査してね
    const formData = new FormData(ev.target)
  }

  return (
    <FormProvider onSubmit={handleSubmit}>
      {({ ref, onSubmit, hasError }) => (
        <form ref={ref} onSubmit={onSubmit}>
          <div>
            <label>User ID</label>
            <FormInput name="user_id" rule="username">
              {(props, errors) => (
                <div>
                  <input {...prop} type="text" autoComplete="username" />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>
          <div>
            <label>Password</label>
            <FormInput name="password" rule="password">
              {(props, errors) => (
                <div>
                  <input
                    {...prop}
                    type="password"
                    autoComplete="new-password"
                  />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>
          <div>
            <label>Password (confirm)</label>
            <FormInput
              name="password_confirm"
              rule="passwordConfirm"
              ruleReferenceInputName="password"
            >
              {(props, errors) => (
                <div>
                  <input
                    {...prop}
                    type="password"
                    autoComplete="new-password"
                  />
                  {errors.map((error) => (
                    <div key={error}>{error}</div>
                  ))}
                </div>
              )}
            </FormInput>
          </div>

          <div>
            <button type="submit" disabled={hasError}>
              Submit
            </button>
          </div>
        </form>
      )}
    </FormProvider>
  )
}

ガチガチにComponentに隠蔽したらこうなる。

    <Form onSubmit={handleSubmit}>
      {({ hasError }) => (
        <FormField label="User ID" name="user_id" required>
          <FormInputUsername />
        </FormField>
        <FormField label="Password" name="password" required>
          <FormInputPassword />
        </FormField>
        <FormField label="Password(confirm)" name="passwordConfirm">
          <FormInputPasswordConfirm referencePropName="password" />
        </FormField>
        <FormField>
          <Button type="submit" disabled={hasError}>Submit</Button>
        </FormField>
      )}
    </Form>

つまるところ、分解しやすく、組み合わせやすいところまでFormの制御要素を整理したいわけよ。

おわりに

−−という未来を目指して書いてるライブラリ、 declaform の解説でした。 まだ完成してない。絶賛道半ばです。

実際やってみて、2010年代のフロントエンド開発とは明らかに難しさが違うな〜と感じている。

TypeScriptとReactのかけ合わせでライブラリ書こうとするとめちゃくちゃむずい。 ライブラリ化せずにただ動くものってレベルだと半日くらいでできていた。型とStateの伝搬の整合性とるのに数日かかった。まじむずい。

なんで、まあ、しばらくしたら「無理っす、Conformつかいましょう」って言ってるかもしれない。 これ一人でなんとかできる気がしない。

世の中のライブラリ開発者の皆さんに、溢れんばかりの感謝と敬意を。