⚡️

pathpidaを使ってNext.js/Nuxt.jsでのURLタイポからオサラバする

2024/02/05に公開

最近joinしたプロジェクトで、あまり見慣れぬpathpidaというライブラリを見かけました。

調べてみたところURLを動的に生成してくれるツールらしいです。

最近URLのタイポにより本来の画面に遷移できない不具合を撲滅しようとしていた自分にはタイムリーなライブラリだったため、色々調べたり試してみた内容を残しておこうと思います。

あらためてpathpidaとは

ディレクトリ構成を読み取ってパスを自動で生成してくれるツールです。

この記事を書いている2024年2月4日現在ではNext.jsNuxt.jsをサポートしています。

https://github.com/aspida/pathpida

ちなみに初見でこのpathpida、正しく発音できる自信がなかったのでGoogle翻訳の自動検出に任せてみたのですが、「パスピダ」と読むそうです。

どうやら英語ではなくギリシャ語で、『苦しみ』という意味らしい。開発者の思いが伝わるいいネーミングだ…

検証環境

検証はNext.jsで行います。

タイムリーなことに先日Next.js関係の記事を書くにあたって公式のサンプルプロジェクトを使ったので、それを再利用します。

https://nextjs.org/learn/dashboard-app

最新版のサンプルプロジェクトはApp Routerベースになっていますが、検証してみるとpathpidaは対応してくれているようでした。

一方で公式ドキュメントのサンプルを見るとPages Router形式になっているみたいなので、Next.jsに関してはどちらでも使えそうです。

pages/index.tsx
pages/post/create.tsx
pages/post/[pid].tsx
pages/post/[...slug].tsx

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

lib/$path.ts or utils/$path.ts // Generated automatically by pathpida

導入

導入は単純にnpm installです。本番には必要ないので、--save-devしておきます。

npm install pathpida --save-dev

package.jsonにコマンド作成

とりあえず最初の作成のために"generate:path": "pathpida --ignorepath .gitignore --output ./utils"としておきます。

オプションはドキュメントを見ていただければよいかと思います🙏

https://github.com/aspida/pathpida?tab=readme-ov-file#command-line-interface-options

再生成は-wまたは--watchをつける必要があるみたいです。

--enableStaticは記事の最後に実行結果を載せておきます。

パス生成してみる

ではpathpidaを使ってパスを作ってみます。

フォルダ構成

今回のフォルダ構成はこんな感じです。

本来dashboard/pathpida/[[...slug]]というキャッチオールセグメントはNext.jsのチュートリアルには登場しないのですが、今回は検証のために作成しました。

一応補足しておくと、キャッチオールセグメントはその後のすべてのセグメントをキャッチするように拡張することができるというやつです。

今回だと、dashboard/pathpida/aaa,dashboard/pathpida/bbbとかでもdashboard/pathpidaの下においたpage.tsxが表示されるという感じです。

ダイナミックルーティング(例えば[slug])の違いとしては、キャッチオールセグメントの場合はdashboard/pathpidaでもdashboard/pathpidaの下においたpage.tsxが表示される点です。

生成結果

npm run generate:pathすると、--outputで指定したutilフォルダに$path.tsというファイルが作成されました。

勝手にパス定義してくれたので、この時点ではタイポの心配がありませんね。

$path.ts
const buildSuffix = (url?: {query?: Record<string, string>, hash?: string}) => {
  const query = url?.query;
  const hash = url?.hash;
  if (!query && !hash) return '';
  const search = query ? `?${new URLSearchParams(query)}` : '';
  return `${search}${hash ? `#${hash}` : ''}`;
};

export const pagesPath = {
  "dashboard": {
    "customers": {
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/customers' as const, hash: url?.hash, path: `/dashboard/customers${buildSuffix(url)}` })
    },
    "invoices": {
      _id: (id: string | number) => ({
        "edit": {
          $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices/[id]/edit' as const, query: { id }, hash: url?.hash, path: `/dashboard/invoices/${id}/edit${buildSuffix(url)}` })
        }
      }),
      "create": {
        $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices/create' as const, hash: url?.hash, path: `/dashboard/invoices/create${buildSuffix(url)}` })
      },
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices' as const, hash: url?.hash, path: `/dashboard/invoices${buildSuffix(url)}` })
    },
    "login": {
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/login' as const, hash: url?.hash, path: `/dashboard/login${buildSuffix(url)}` })
    },
    "pathpida": {
      _slug: (slug?: string[]) => ({
        $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/pathpida/[[...slug]]' as const, query: { slug }, hash: url?.hash, path: `/dashboard/pathpida/${slug?.join('/')}${buildSuffix(url)}` })
      })
    }
  },
  $url: (url?: { hash?: string }) => ({ pathname: '/' as const, hash: url?.hash, path: `/${buildSuffix(url)}` })
};

export type PagesPath = typeof pagesPath;

生成されたファイルをみるとbuildSuffixという関数がいて、これがいるおかげでアンカータグがあったりクエリパラメーターがあっても問題なく使えそうです。

ただ今のままだとファイル名を変更すると$path.tsが追従しません。

しかし、この問題はオプション-wまたは--watchをつけておくことで解消できます。

試しにwatchモードでpathpidaからpathpidaRenameに変更してみると、変更時にファイルの再生成に成功した旨のログが表示され、実際に$path.tsも書き変わっていることが確認できました。

静的ファイルパスの生成

ここまではappディレクトリ以下の動的ファイルを見てきましたが、次は静的ファイルを見てみます。

以下のような構成で画像ファイルがあります。

静的ファイルのパスも生成するには-sまたは--enableStaticオプションをつけます。

$path.ts
const buildSuffix = (url?: {query?: Record<string, string>, hash?: string}) => {
  const query = url?.query;
  const hash = url?.hash;
  if (!query && !hash) return '';
  const search = query ? `?${new URLSearchParams(query)}` : '';
  return `${search}${hash ? `#${hash}` : ''}`;
};

export const pagesPath = {
  "dashboard": {
    "customers": {
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/customers' as const, hash: url?.hash, path: `/dashboard/customers${buildSuffix(url)}` })
    },
    "invoices": {
      _id: (id: string | number) => ({
        "edit": {
          $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices/[id]/edit' as const, query: { id }, hash: url?.hash, path: `/dashboard/invoices/${id}/edit${buildSuffix(url)}` })
        }
      }),
      "create": {
        $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices/create' as const, hash: url?.hash, path: `/dashboard/invoices/create${buildSuffix(url)}` })
      },
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/invoices' as const, hash: url?.hash, path: `/dashboard/invoices${buildSuffix(url)}` })
    },
    "login": {
      $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/login' as const, hash: url?.hash, path: `/dashboard/login${buildSuffix(url)}` })
    },
    "pathpida": {
      _slug: (slug?: string[]) => ({
        $url: (url?: { hash?: string }) => ({ pathname: '/dashboard/pathpida/[[...slug]]' as const, query: { slug }, hash: url?.hash, path: `/dashboard/pathpida/${slug?.join('/')}${buildSuffix(url)}` })
      })
    }
  },
  $url: (url?: { hash?: string }) => ({ pathname: '/' as const, hash: url?.hash, path: `/${buildSuffix(url)}` })
};

export type PagesPath = typeof pagesPath;

+ export const staticPath = {
+   customers: {
+     amy_burns_png: '/customers/amy-burns.png',
+     balazs_orban_png: '/customers/balazs-orban.png',
+     delba_de_oliveira_png: '/customers/delba-de-oliveira.png',
+     emil_kowalski_png: '/customers/emil-kowalski.png',
+     evil_rabbit_png: '/customers/evil-rabbit.png',
+     guillermo_rauch_png: '/customers/guillermo-rauch.png',
+     hector_simpson_png: '/customers/hector-simpson.png',
+     jared_palmer_png: '/customers/jared-palmer.png',
+     lee_robinson_png: '/customers/lee-robinson.png',
+     michael_novotny_png: '/customers/michael-novotny.png',
+     steph_dietz_png: '/customers/steph-dietz.png',
+     steven_tey_png: '/customers/steven-tey.png'
+   },
+   favicon_ico: '/favicon.ico',
+   hero_desktop_png: '/hero-desktop.png',
+   hero_mobile_png: '/hero-mobile.png',
+   opengraph_image_png: '/opengraph-image.png'
+ } as const;
+ 
+ export type StaticPath = typeof staticPath;

pathpidaで生成したパスを使う

基本的な使い方はこんな感じです。$path.tsのコードが読めればそんなに難しくないかなと思います。

import { pagesPath } from '@/utils/$path';

console.log(pagesPath.dashboard.customers.$url());
console.log(pagesPath.dashboard.customers.$url({ hash: "Anchor" }));
console.log(pagesPath.dashboard.invoices._id(1).edit.$url());
console.log(pagesPath.dashboard.pathpida._slug(["a"]).$url());
console.log(pagesPath.dashboard.pathpida._slug(["aaa", "bbb"]).$url());

出力は以下の通りです。簡単ですね。

キャッチオールセグメントに対するパスの書き方だけ少し違いますが、配列に記載することで/区切りでパスにしてくれています。

{
  pathname: '/dashboard/customers',
  hash: undefined,
  path: '/dashboard/customers'
}
{
  pathname: '/dashboard/customers',
  hash: 'Anchor',
  path: '/dashboard/customers#Anchor'
}
{
  pathname: '/dashboard/invoices/[id]/edit',
  query: { id: 1 },
  hash: undefined,
  path: '/dashboard/invoices/1/edit'
}
{
  pathname: '/dashboard/pathpida/[[...slug]]',
  query: { slug: [ 'a' ] },
  hash: undefined,
  path: '/dashboard/pathpida/a'
}
{
  pathname: '/dashboard/pathpida/[[...slug]]',
  query: { slug: [ 'aaa', 'bbb' ] },
  hash: undefined,
  path: '/dashboard/pathpida/aaa/bbb'
}

クエリパラメーターをつけてみる

少し実践を意識してみましょう。今はcustomersページはクエリパラメーターを受け取っていません。

しかし実際にページを作る中ではクエリパラメーターを必ず受け取る場合時と場合によるがクエリパラメーターを受け取る場合があると思います。

pathpidaを使うとこの2つのケースをコードで表現しつつ、楽に実装していくことができます。

クエリパラメーターが必須の場合

そのページでクエリパラメーターが必須の場合はpage.tsxQueryという名前の型を定義し、exportします。

customers.tsx
export type Query = {
    userId: number
    name?: string
}

export default function Page() {
    return <p>Customers Page</p>;
}

この状態でpathpidaでパスを再生成すると、page.tsxで定義した型が競合しないようにハッシュ付きのエイリアスをつけつつ、$path.tsにimportされています。

その結果、実際に$path.tsに定義されたパスを使っているところがエラーになっています。

ここに定義したQueryに合うオブジェクトを渡してあげると、エラーが解消しました。(べんり〜)

クエリパラメーターを受け取る可能性がある場合

このときはQueryと同じ要領でOptionalQueryという型を定義してあげます。

この状態でpathpidaでパスを再生成すると、Queryと同じく、$path.tsにimportされています。

一方で今回はOptionalのため、$path.tsに定義されたパスを使っているところはクエリパラメーターを渡していなくてもエラーになっていません。

おわりに

かなり使い勝手が良く、クエリパラメーターの必須orNotについてもよく考えられているな〜と思いました。

この記事を書いてる間ウォッチモードで動かしていたのですが、パス書き換えがかなり気軽にできるのでリンクが変わっても水平展開が漏れることなく使えそうなところも素晴らしいです。

Discussion