pathpidaを使ってNext.js/Nuxt.jsでのURLタイポからオサラバする
最近joinしたプロジェクトで、あまり見慣れぬpathpida
というライブラリを見かけました。
調べてみたところURLを動的に生成してくれるツールらしいです。
最近URLのタイポにより本来の画面に遷移できない不具合を撲滅しようとしていた自分にはタイムリーなライブラリだったため、色々調べたり試してみた内容を残しておこうと思います。
あらためてpathpidaとは
ディレクトリ構成を読み取ってパスを自動で生成してくれるツールです。
この記事を書いている2024年2月4日現在ではNext.js
とNuxt.js
をサポートしています。
ちなみに初見でこのpathpida
、正しく発音できる自信がなかったのでGoogle翻訳の自動検出に任せてみたのですが、「パスピダ」と読むそうです。
どうやら英語ではなくギリシャ語で、『苦しみ』という意味らしい。開発者の思いが伝わるいいネーミングだ…
検証環境
検証はNext.js
で行います。
タイムリーなことに先日Next.js
関係の記事を書くにあたって公式のサンプルプロジェクトを使ったので、それを再利用します。
最新版のサンプルプロジェクトは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"
としておきます。
オプションはドキュメントを見ていただければよいかと思います🙏
再生成は-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
というファイルが作成されました。
勝手にパス定義してくれたので、この時点ではタイポの心配がありませんね。
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
オプションをつけます。
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.tsx
にQueryという名前の型を定義し、export
します。
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