🛡️

設定0行でNext.jsとNuxt.jsの内部リンクを型安全に取得できる最強ライブラリ「pathpida」

2020/12/25に公開

Qiita TypeScript Advent Calendar 2020 最終日の記事です。

TypeScript製の内部リンク取得ライブラリ「pathpida」

最近ちょっと話題になった frourioaspida を開発したSolufaです。
Zenn初投稿を記念して、新作ライブラリ「pathpida」を紹介します。

と言っても完全な新作ではなく、初回リリースからもうすぐ1年が経ち月間DL数は1,000を超えています。自分が関わる案件だけで静かに検証を続け、ようやっと今週全面リニューアルして一般告知が出来るようになりました!

pathpidaはNext.jsとNuxt.jsそれぞれのルーティング規約に最適化しているので設定不要で型安全に使うことが出来ます。

pathpida

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

以下のように/post/1 に遷移するLinkがあるとします。

import Link from 'next/link'

export default () => {
  const url = `/post/${1}`
  return <Link href={url} />
}

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

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

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

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

pages/[pid].tsx
pages/blog/[...slug].tsx
pages/index.tsx

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

lib/$path.ts
export const pagesPath = {
  _pid: (pid: number | string) => ({
    $url: () => ({ pathname: '/[pid]', query: { pid }})
  }),
  blog: {
    _slug: (slug: string[]) => ({
      $url: () => ({ pathname: '/blog/[...slug]', query: { slug }})
    })
  },
  $url: () => ({ pathname: '/' })
}

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

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

pages/index.tsx
import Link from 'next/link'
import { pagesPath } from '../lib/$path'

export default () => {
  return <Link href={pagesPath.blog._slug(['a', 'b', 'c']).$url()} />
}

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

Next.jsで使ってみよう

Next.js + TypeScriptの環境がすでに出来ている前提で説明を進めます。

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

$ yarn add pathpida npm-run-all --dev
package.json
{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:next": "next dev",
    "dev:path": "pathpida --watch",
    "build": "pathpida && next build"
  }
}

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

$ yarn dev

utils または lib ディレクトリどちらかに $path.ts ファイルが自動生成されます。
※両方存在しない場合はlibディレクトリを自動作成
※v0.10.0で src/pages src/lib にも対応

lib/
  $path.ts
pages/
  _app.tsx
  index.tsx
lib/$path.ts
export const pagesPath = {
  $url: () => ({ pathname: '/' })
}

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

pagesPathをインポートしてNext.jsのlinkやrouterを今まで通りに使えます。

lib/
  $path.ts
pages/
  articles/
    [id].tsx
  users/
    [...userInfo].tsx
  _app.tsx
  index.tsx
components/ActiveLink.tsx
import Link from 'next/link'
import { useRouter } from 'next/router'
import { pagesPath } from '../lib/$path'

function ActiveLink() {
  const router = useRouter()

  const handleClick = () => {
    router.push(pagesPath.users._userInfo(['mario', 'hello', 'world!']).$url())
  }

  return <>
    <div onClick={handleClick}>Hello</div>
    <Link href={pagesPath.articles._id(1).$url()}>
      World!
    </Link>
  </>
}

export default ActiveLink

必須クエリを指定

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

pages/user.tsx
export type Query = {
  userId: number
}

export default () => <div />

と書くだけです。

import { pagesPath } from '../lib/$path'

pagesPath.user.$url({ query: { userId: 1 }})

このようにクエリをセットできます。

オプションでクエリを指定

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

pages/user.tsx
export type OptionalQuery = {
  userId: number
}

export default () => <div />

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

import { pagesPath } from '../lib/$path'

pagesPath.user.$url({ query: { userId: 1 }})
pagesPath.user.$url()

ハッシュを指定

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

import { pagesPath } from '../lib/$path'

pagesPath.user.$url({ query: { userId: 1 }, hash: 'hoge' })
pagesPath.user.$url({ hash: 'fuga' })

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

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

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

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

public/aa.json
public/bb/cc.png

lib/$path.ts or utils/$path.ts

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

pages/index.tsx
import Link from 'next/link'
import { staticPath } from '../lib/$path'

console.log(staticPath.aa_json) // /aa.json

export default () => <img src={staticPath.bb.cc_png} />

Nuxt.jsならVue/Vuexインスタンスに対応

Nuxt.js + TypeScriptで使う場合は1行だけ設定追加が必要です。(タイトル詐欺…)

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

これだけでVue/Vuexインスタンスから $pagesPath でアクセスできます。
※enableStaticオプションを使えばstaticのファイルパスも $staticPath で取得可能

pages/index.vue
<template>
  <div>
    <nuxt-link :to="$pagesPath.post._pid(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.post._pid(1).$url())
    }
  }
})
</script>

pathpidaはプロジェクトルートに nuxt.config.js(ts) があればNuxt.js、なければNext.jsだと判定します。
Next.jsとNuxt.jsでは$path.tsの内容が異なるので注意してください。

※Nuxt.jsの場合、pathpidaはハイフンから始まるファイルを無視する
※VueファイルのQuery型をTSファイルでimport出来ない都合上、Nuxt.jsでは型参照を持つQueryを指定することが出来ない(詳細はNuxt.js環境で$path.tsのQuery定義箇所を参照)

今後の展開

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

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

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

サポート

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

Discussion