【設定1行】Nuxt.jsとNext.jsの内部リンクを型で正確に取得できるTS製の最強ライブラリ「pathpida」

2020年10月に以下の記事でちょっと話題になった [frourio](https://frourio.io/) の開発者です。
[憧れのTypeScriptフルスタック環境がコマンド1発で作れる超軽量フレームワーク「frourio」](https://qiita.com/m_mitsuhide/items/00b139bb565dddf8006a)

1年前くらいに自分のプロジェクトで使うために開発した「[pathpida](https://github.com/aspida/pathpida)」がリニューアルでメチャクチャ便利になったのでみなさんにも紹介します。
pathpidaはNuxt.jsとNext.jsそれぞれのルーティング規約に最適化しているのでたった1行の設定で型安全に使うことが出来ます。

![logo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63528/6fb087bd-924e-b87c-1645-f76174c494a1.png)

# どんな問題を解決するのか

以下のように `/post/1` に遷移する `nuxt-link` があるとします。

```vue
<template>
  <nuxt-link :to="link" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  data() {
    return {
      postId: 1
    }
  },
  computed: {
    link() {
      return `/post/${this.postId}`
    }
  }
})
</script>
```

この時、`/post/{this.postId}` が内部リンクとして適切かどうかを静的に型検査することはできません。
もし `pages/post/_postId.vue` というファイルが存在しなければページ遷移に失敗します。

TypeScript4.1で追加されたTemplate Literal Typesを使おうとしてもpagesにある大量のファイルパスを手書きするのは困難です。
開発途中でURLが変更になりpages内のファイル名を変えた際に、膨大なソースコード内から目視で古いパスを探し出して書き換える作業もみな経験してきたことでしょう。

そんな問題を解決するのがpathpidaです。
名前の通り、pagesディレクトリを見て内部リンク取得用クライアントを自動生成してくれる非常に賢いライブラリ。

例えば、pagesディレクトリが以下の状態のとき

```
pages/post/_postId.vue
pages/post/index.vue
pages/index.vue
```

以下のTypeScriptファイルが自動生成されます。

```ts:plugins/$path.ts
export const pagesPath = {
  post: {
    _postId: (postId: number | string) => ({
      $url: () => ({ path: `/post/${postId}` })
    }),
    $url: () => ({ path: '/post' })
  },
  $url: () => ({ path: '/' })
}
```

見ての通りpagesPathのプロパティがpagesのファイルと対応。
$urlメソッドが返すオブジェクトはそのまま `nuxt-link` や `$router` に渡すことが出来ます。

コンポーネントで使うイメージはこんな感じ

```vue:components/Sample.vue
<template>
  <nuxt-link :to="link" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  data() {
    return {
      postId: 1
    }
  },
  computed: {
    link() {
      return this.$pagesPath.post._postId(this.postId).$url()
    }
  }
})
</script>
```

もちろん `nuxt-link` に直接セットすることもできます。

```vue:components/Sample.vue
<template>
  <nuxt-link :to="$pagesPath.post._postId(postId).$url()" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  data() {
    return {
      postId: 1
    }
  }
})
</script>
```

とっても手軽かつ型安全に内部リンクを扱うことが出来るのです！

# Nuxt.jsで使ってみよう

Nuxt.js + TypeScriptの環境がすでに出来ている前提で説明を進めます。
Next.jsユーザーは以下の記事を参考にしてください。
[Zenn - 設定0行でNext.jsとNuxt.jsの内部リンクを型安全に取得できる最強ライブラリ「pathpida」](https://zenn.dev/solufa/articles/renewed-pathpida)

pathpidaの他にnpm-run-allもインストールします。
依存関係ではないのですが、開発中はnpm-run-allがあると便利です。

```sh
$ yarn add pathpida npm-run-all --dev
```

```json:package.json
{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:nuxt": "nuxt-ts",
    "dev:path": "pathpida --watch",
    "build": "pathpida && nuxt-ts build"
  }
}
```

後で `plugins/$path.ts` が自動生成されるので `nuxt.config.js(ts)` に設定を1行追加します。

```js:nuxt.config.js
{
  plugins: ['~/plugins/$path']
}
```

devコマンドでNuxt.jsと一緒にpathpidaも監視モードで起動します。

```sh
$ yarn dev
```

`plugins/$path.ts` ファイルが自動生成されます。

```
pages/
  index.vue
plugins/
  $path.ts
```

```ts:plugins/$path.ts
import { Plugin } from '@nuxt/types'

export const pagesPath = {
  $url: () => ({ pathname: '/' })
}

const pathPlugin: Plugin = (_, inject) => {
  inject('pagesPath', pagesPath)
}

export default pathPlugin
```

あとはpagesディレクトリにファイルを増やすたびに `plugins/$path.ts` ファイルが書き変わります。
※Nuxt.jsの場合、pathpidaはハイフンから始まるファイルを無視する

VueコンポーネントやVuexで `this.$pagesPath` を呼び出して `nuxt-link` や `$router` と今まで通りに組み合わせて使えます。

```
pages/
  articles/
    _id.vue
  users/
    _userId.vue
  index.vue
plugins/
  $path.ts
```

```vue:components/ActiveLink.vue
<template>
  <div>
    <nuxt-link :to="$pagesPath.articles._id(1).$url()" />
    <div @click="onclick" />
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  methods: {
    onclick() {
      this.$router.push(this.$pagesPath.users._userId(1).$url())
    }
  }
})
</script>
```

## 必須クエリを指定

pages配下のコンポーネントでQuery型をexportするだけでクエリを指定できます。
例えば、 `/users?userId={number}` というページを作るには

```vue:pages/users.vue
<template>
  <div />
</template>

<script lang="ts">
import Vue from 'vue'

+ export type Query = {
+  userId: number
+ }

export default Vue.extend({
})
</script>
```

と書くだけです。他のコンポーネントからは

```vue
<template>
  <div @click="onclick" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  methods: {
    onclick() {
      this.$router.push(this.$pagesPath.users.$url({ query: { userId: 1 }))
    }
  }
})
</script>
```

このようにクエリをセットできます。
注意点は、Query型に他の型の参照を含んではいけないということ。
VueファイルからQueryをexportしているものの、 `plugins/$path.ts` ではimport出来ないのです。
そこでpathpidaはQuery型を正規表現で `plugins/$path.ts` にコピペする処理をしています。

```ts:plugins/$path.ts
import { Plugin } from '@nuxt/types'

+ export type Query = {
+  userId: number
+ }

export const pagesPath = {
  users: {
    $url: (url: { query: Query }) => ({ path: '/users', query: url.query })
  }
}

const pathPlugin: Plugin = (_, inject) => {
  inject('pagesPath', pagesPath)
}

export default pathPlugin
```

なので以下のように型参照を含んでいると型でエラーになります。

```vue:pages/users.vue
<template>
  <div />
</template>

<script lang="ts">
import Vue from 'vue'

+ type UserId = number

+ export type Query = {
+  userId: UserId
+ }

export default Vue.extend({
})
</script>
```

```ts:plugins/$path.ts
import { Plugin } from '@nuxt/types'

+ export type Query = {
+  userId: UserId // UserIdが見つからなくてエラー
+ }

export const pagesPath = {
  users: {
    $url: (url: { query: Query }) => ({ path: '/users', query: url.query })
  }
}

const pathPlugin: Plugin = (_, inject) => {
  inject('pagesPath', pagesPath)
}

export default pathPlugin
```

TypeScript Compiler APIで何とかすることも出来そうですが、クエリは単純な型が一般的で参照を使わずとも運用回避できると考えシンプルに正規表現でコピペする方法を採用しました。

## オプションのクエリを指定

上記の方法だと、クエリのプロパティ全てをオプショナルにしても$urlに空のオブジェクトを渡す必要があります。
クエリを渡すことそのものをオプショナルにするにはOptionalQuery型をexportします。

```vue:pages/users.vue
<template>
  <div />
</template>

<script lang="ts">
import Vue from 'vue'

+ export type OptionalQuery = {
+  userId: number
+ }

export default Vue.extend({
})
</script>
```

これで$urlを空で呼ぶことが可能になります。

```vue
<template>
  <div @click="onclick" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  methods: {
    onclick() {
      this.$router.push(this.$pagesPath.users.$url({ query: { userId: 1 }))
      this.$router.push(this.$pagesPath.users.$url())
    }
  }
})
</script>
```

## ハッシュを指定

$urlメソッドの引数に好きな文字列のhashプロパティを渡すだけです。
`#` は自動で付与されます。

```vue
<template>
  <div @click="onclick" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  methods: {
    onclick() {
      this.$router.push(this.$pagesPath.users.$url({ query: { userId: 1 }, hash: 'hoge' ))
      this.$router.push(this.$pagesPath.users.$url({ hash: 'fuga' }))
    }
  }
})
</script>
```

## staticディレクトリの静的ファイルのパスも型安全に取得

pathpidaコマンドにenableStaticオプションを渡すと、staticディレクトリの内容からstaticPathクライアントを生成します。

```json:package.json
{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:nuxt": "nuxt-ts",
    "dev:path": "pathpida --enableStatic --watch",
    "build": "pathpida --enableStatic && nuxt-ts build"
  }
}
```

publicにJSONとPNG画像がある想定

```
plugins/$path.ts

static/aa.json
static/bb/cc.png
```

staticPathクライアントは$urlメソッドが無くて直接パス文字列を取得できます。


```vue
<template>
  <img :src="$staticPath.bb.cc_png" />
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  created() {
    console.log(this.$staticPath.aa_json) // /aa.json
  }
})
</script>
```

# 今後の展開

年末年始の休暇中にSapper(Svelte)のパス生成にも対応する予定です。
そのあとサンプルコードが揃ったらcreate-frourio-appに組み込みます。

そして2021年は・・・限界まで資金を投入してfrourioエコシステムの海外展開を行います！
Railsを倒せるかどうかに関心はありませんが、TypeScriptのフルスタックフレームワークと言えばfrourio一強という未来を実現したいですね。

最後まで読んでいただきありがとうございました。
GitHubにスターを押して行ってもらえるとオープンソース開発の活力になります！
https://github.com/aspida/pathpida

# サポート

年末年始もNetflixを観ながらダラダラとコードを書いて過ごすだけなのでpathpidaやfrourioの質問・要望をお気軽にください。（魔女の旅々はいいぞ）

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">LIGブログにfrourioの紹介記事が掲載されました🎉🎉<br>執筆者はLIGのCTOづやさんです<br><br>- 数時間ではてぶホットエントリ<br>- 1日でLIGブログの殿堂入り<br>- GitHubスター38個増<br>- npmのDL数400増<br>など絶好調な滑り出し<br><br>読んでくれたみなさんありがとうございます！<a href="https://t.co/4p1yb7ujsc">https://t.co/4p1yb7ujsc</a></p>&mdash; Solufa (@m_mitsuhide) <a href="https://twitter.com/m_mitsuhide/status/1339373391388626945?ref_src=twsrc%5Etfw">December 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>