@k8ordo/static

パラメータ

パラメータは文字列で届き、スキーマを宣言すれば型の付いた値になります。このモードではビルドがすべての値を前もって知る必要があり、それを渡すのが paths です。

パラメータは文字列で届く

URL が運べるのは文字列だけなので、何も宣言しなければ params の値はすべて文字列です。[id] の下のページは params.id: string を受け取ります。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return <h1>{params.id.toUpperCase()}</h1>;
}

スキーマで形を言う

page.tsxlayout.tsxparamsSchema を export して、受け取るものの形を言えます。レイアウトのスキーマは、その下のすべてのページに効きます。名前が params でないのは、ページ自身の prop が params で、同じ名前のモジュール変数はそれを隠してしまうからです。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return <h1>{params.id.toFixed(0)}</h1>;
}

Standard Schema を実装したライブラリなら何でも使えます。zod、zod/mini、ほかのライブラリ、そして @k8ordo/i18nlocales.paramsSchema もそうです。仕様はリンク先にあります。 Standard Schema

export はファイルを構文解析して見つけるので、書き方は問いません。export const paramsSchema = …export { paramsSchema }、分割代入の export const { paramsSchema } = locales のどれも export です。文字列やコメントの中の同じ単語と export type は数えません。読まれるのは page.tsxlayout.tsx だけで、構文として解析できないファイルは何も宣言していないものとして扱われます。

スタックに沿ってスキーマが順に走る

ページが描かれる前に、そのページの上で宣言されたスキーマが外側のレイアウトから順に走り、最後にページ自身のものが走ります。それぞれは自分が名指した文字列を自分の出力で置き換え、どのスキーマも名指さなかったパラメータは文字列のまま残ります。

次の例では、[locale] のレイアウトがロケールを、ページが id を検証し、ページは両方の結果を受け取ります。

// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';

export const locales = defineLocales(['ja', 'en']);
// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return <>{children}</>;
}
// src/routes/[locale]/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({
  params,
}: PageProps<'/:locale/products/:id'>) {
  return <h1 lang={params.locale}>{params.id.toFixed(0)}</h1>;
}

拒まれた値には、そのパターンが答えない

スキーマが値を拒んでも、/products/shoesNaN を持ったページになることはありません。照合はそのパターンが一致しなかったものとして表の次へ進み、最後は not-found.tsx が 404 として答えます。ディレクトリが最初から一致しなかったのと同じです。

このモードでは、その 404 は 404.html という 1 枚のファイルです。一方、paths で渡した pathname をスキーマが拒んだ場合は、サイトにあると言っている URL に 404 のページを書くことになるので、ビルドが止まります(下の「ビルドが止まるとき」)。

catch-all 自身のパラメータは検証されません。catch-all はほかのどれも答えなかったものに答えるので、値を拒んだときに返るはずの 404 がすでにそこにあります。/:locale/*not-found.tsx が受け取る params.locale は、どんな文字列でもありえます。

スキーマは同期的に

どのパターンが pathname に答えるかは、何かが描かれる前に決まり、その判断は待てません。非同期に検証するスキーマは、描画の前に次のエラーで拒まれます。値が形として正しいかはスキーマが、データとして存在するかはページが確かめます。

TypeError: a params schema must validate synchronously which pattern answers a pathname is decided before anything renders

スキーマは Server Component のファイルに置く

'use client' のモジュールから export した値は、RSC 側にはスキーマではなく client reference として届き、ハンドラはそれを走らせられません。クライアントコンポーネントにしたいレイアウトは、スキーマを Server Component の layout.tsx に置き、そこからクライアント側の殻を描きます。このサイトの [locale] レイアウトがその形です。

// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';

export const { paramsSchema } = locales;

export default function LocaleLayout({
  params,
  children,
}: {
  params: { locale: string };
  children: ReactNode;
}) {
  return <LocaleShell locale={params.locale}>{children}</LocaleShell>;
}

殻の側の書き方は、リンク先にあります。 実行境界

ページもリンクも、スキーマの出力を受け取る

生成された Register は、パターンごとにスキーマの出力型を持っています。PageProps<'/products/:id'>params.id は number になり、href('/products/:id', { id: 42 }) も number を受け取って、スキーマが読み戻せる 1 通りの綴りで書きます。そこに文字列やオブジェクトを渡すと型エラーです。

// src/routes/products/page.tsx
import { href } from '@k8ordo/router';

export default function ProductsPage() {
  return (
    <ul>
      {[1, 2, 3].map((id) => (
        <li key={id}>
          <a href={href('/products/:id', { id })}>
            {`product ${String(id)}`}
          </a>
        </li>
      ))}
    </ul>
  );
}

生成された表は各スキーマを satisfies ParamsSchemaFor<pattern> で検査しますが、この検査は緩やかです。パラメータを持つパターンで、そのどれも名指さないスキーマは、tsc.k8ordo/routes.gen.ts でエラーにします。一方、実在するパラメータと一緒にパターンに無いキーを名指すスキーマは通ります。そうしたキーは無害ではありません。パターンに無いパラメータを必須にしたスキーマはすべての pathname を拒むので、そのページは決して答えません。vite build は型を検査しないので、上のエラーも tsc を走らせたときにしか出ません。

レイアウトの params は文字列として型が付く

レイアウトの params は、自分でスキーマを宣言していても文字列として型が付きます(LayoutProps でも、生成された Layout でも)。同じレイアウトは not-found.tsx のまわりでも描かれ、そこでは何も検証されないからです。ただし実行時の値はこの型のとおりではありません。ページのまわりではスタックのスキーマが出したページの値(スキーマが数値にしたなら数値)が届き、not-found.tsx のまわりでは URL の文字列がそのまま届きます。型が文字列だからといって文字列のメソッドを呼ばず、1 つの形が要るならレイアウト自身が String() で揃えるか、型の付いた値は下のページに受け取らせます。

// src/routes/products/[id]/layout.tsx
import type { LayoutProps } from '@k8ordo/router';

export default function ProductLayout({
  params,
  children,
}: LayoutProps<'/products/:id'>) {
  return <section data-product={params.id}>{children}</section>;
}

LayoutProps<pattern> が受け取るのは、表がその位置にページを持つパターンだけです。型の制約がページのパターンだからです。自分の位置にページが無いレイアウト(ページがすべて shop/[id]/ の下にある shop/layout.tsx など)は、props をインラインで書きます。

paths: パラメータの値をビルドに渡す

事前の描画はパラメータの値を発明できないので、ビルドが値を尋ねます。framework()paths は、まだ値が要るパターンの一覧を受け取り、具体的な pathname の配列か、その Promise を返す関数です。

// vite.config.ts
import { readFile } from 'node:fs/promises';

import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

type Product = { id: number };

export default defineConfig({
  plugins: [
    framework({
      paths: async () => {
        const products = JSON.parse(
          await readFile('data/products.json', 'utf8'),
        ) as Product[];
        return products.map(
          (product) => `/products/${String(product.id)}`,
        );
      },
    }),
  ],
});

パラメータの無いルートは表から取られるので、渡す必要はありません。渡しても冗長なだけで、誤りにはなりません。末尾のスラッシュはルーターと同じく無視され、href() が返すエスケープ済みの pathname はそのまま受け付けます。/products/caf%C3%A9products/café/index.html に書かれます。

パラメータの下にある redirect.ts[locale]/legacy/redirect.ts など)も、渡されるパターンに含まれます。リダイレクトもサイトが持つ URL で、ファイルとして書かれるからです。not-found.tsx のパターンは含まれません。

どこでも同じ値を取るパラメータ

パターンが手渡されるので、ロケールの区間のように全ページに掛かるパラメータは、ページごとに列挙せず展開できます。次の例は区間ごとに見て :locale だけを置き換え、それを持たないパターンはそのまま返します。そのまま返したパターンにパラメータが残っていれば、ビルドがそれを名指して値を求めます。

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      paths: (patterns) =>
        patterns.flatMap((pattern) => {
          const segments = pattern.split('/');
          if (!segments.includes(':locale')) return [pattern];
          return ['ja', 'en'].map((locale) =>
            segments
              .map((segment) => (segment === ':locale' ? locale : segment))
              .join('/'),
          );
        }),
    }),
  ],
});

このサイトの vite.config.ts は、@k8ordo/i18nlocales.paths をそのまま渡しています。/:locale の区間を持つパターンをロケールの数だけ展開し、ほかのパラメータには手を付けない関数です。

// vite.config.ts
import { framework } from '@k8ordo/static';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';

export default defineConfig({
  plugins: [
    framework({
      site: 'https://ordo.k8o.me',
      paths: locales.paths,
    }),
    tailwindcss(),
  ],
});

2 つのパラメータを持つパターンの片方だけを展開すると、/ja/blog/:slug のような値が残ります。これは pathname ではないので拒まれ、/:locale/blog/:slug は値が無いままになります。残ったパラメータは自分で展開してから返します。

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';

const slugs = ['hello-world', 'second-post'];

export default defineConfig({
  plugins: [
    framework({
      paths: (patterns) =>
        locales
          .paths(patterns)
          .flatMap((pathname) => {
            const segments = pathname.split('/');
            if (!segments.includes(':slug')) return [pathname];
            return slugs.map((slug) =>
              segments
                .map((segment) => (segment === ':slug' ? slug : segment))
                .join('/'),
            );
          }),
    }),
  ],
});

ビルドが止まるとき

ページの半分が黙って欠けたサイトを出荷するより、ビルドが止まる方がましです。paths まわりでは次の場合にビルドが止まります。最初の 3 つは該当するパターンか pathname をすべて名指し、エスケープにまつわる 2 つは最初に見つかった 1 つを名指します。

いつエラー
パラメータ付きのパターンを、渡された pathname が 1 つも覆わないstatic build needs pathnames for /products/:id — supply them with the "paths" option
どのパターンにも一致しない pathname。打ち間違いか、パラメータが残った値the "paths" option supplied pathnames no route wants: /produtcs/2
ページのスキーマが拒む pathnamethe "paths" option supplied pathnames a params schema refused: /products/shoes
復号できないエスケープを含む pathnamethe "paths" option supplied a pathname with a malformed escape: /products/%zz
復号すると出力ディレクトリの外を指す pathnamethe "paths" option supplied a pathname that leaves the output: /products/..%2F..