Nette Documentation Preview

syntax
スラッグを使った読みやすい URL
*********************

.[perex]
`/article/123-how-to-bake-bread` のような URL は `/article/123` より見栄えがよく、ユーザーにも検索エンジンにもページの内容を伝えてくれます。このガイドでは、テンプレートに一切手を触れずにルーターだけでそれを生成する方法と、すべての訪問者が正規の URL にたどり着くようにする方法を紹介します。


なぜ URL にスラッグを入れるのか
====================

2 つのアドレスを比べてみましょう。

```
/article/123
/article/123-how-to-bake-bread
```

2 つめは、クリックした先に何があるのかをユーザー(そして Google)に伝えます。SEO に良く、チャットやメールの中でリンクが読みやすくなり、アドレスバーにも意味が生まれます。

ただしスラッグは本当の識別子ではありません。ページを決めるのは ID です。スラッグはアプリケーションがタイトルから作る飾りです。タイトルが変われば、スラッグも変わるべきです。そして誰かが URL を手で書き換えたり、古いリンクをたどったりしても、アプリケーションは正しいページを見つけられるべきです。


目標
===

次のすべてを扱えるルートがほしいとします。

```
/article/123                              → 記事 123 を開き、正規の URL にリダイレクト
/article/123-how-to-bake-bread            → 記事 123 を直接開く
/article/123-anything-someone-typed       → 記事 123 を開き、正規の URL にリダイレクト
/article/                                 → 404(ID がない)
```

そしてアプリケーション全体のすべての `n:href` と `link()` の呼び出しが、**テンプレートを 1 行も書き換えずに**自動的に `/article/123-how-to-bake-bread` を生むようにしたいのです。


ルートのマスク
========

仕掛けは、角かっこを使ってマスクの中でスラッグを**省略可能**にすることです。

```php
$router->addRoute('article/<id [0-9]+>[-<slug>]', 'Article:detail');
```

マスク `[-<slug>]` は「ID のあとにハイフンとスラッグが続くかもしれないが、必須ではない」という意味です。このルートは `/article/123` も `/article/123-anything` も受け付けます。

パラメータ `<slug>` について一言。既定では**スラッシュ以外の**任意の文字に一致します。まさに望みどおりです。`<slug .+>` と書くとスラッシュにも一致するので、`/article/123-something/else` が `/` を含むひとつのスラッグとして解析されてしまいます。本当に必要でない限り、既定の `<slug>` のままにしてください。

ここまでで URL は正しく解析されますが、生成されるリンクにはスラッグが入りません。次はスラッグを埋める方法をルートに教えます。


テンプレートに触れずにスラッグを生成する
======================

これが決め手となるやり方です。既存の `n:href="Article:detail, $id"` の呼び出しは、アプリケーション全体でそのまま動き続けます。ルーターが自分でタイトルを調べてくれるのです。

そのために、空文字列のキーの下に**一般フィルタ**を置きます。すべてのパラメータを一度に見られるので、スラッグを足せます。

```php
use Nette\Routing\Route;
use Nette\Utils\Strings;

$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'' => [
		Route::FilterOut => function (array $params) use ($slugProvider): array {
			if (isset($params['id']) && empty($params['slug'])) {
				$params['slug'] = $slugProvider->getSlug((int) $params['id']);
			}
			return $params;
		},
	],
]);
```

`FilterOut` は、ルーターが URL を**生成する**たびに走ります。スラッグが渡されていなければ、フィルタがタイトルを調べて足します。

たったひとつのルート定義を変えるだけで、アプリケーション全体にスラッグを導入できます。あらゆるテンプレートのあらゆるリンクが、自動的に `/article/123-how-to-bake-bread` を出すようになります。grep も、テンプレート探しも、見落としもありません。


検索結果をキャッシュする
=============

リンク 1 本につき DB のクエリが 1 回走りますが、ふつうのページにはリンクがたくさんあります。一覧、パンくず、「最近見たもの」、関連記事などです。ひとつのリクエストの中で同じ記事 ID が複数のリンクに現れることも多く、そのたびにデータベースを叩きたくはありません。

リクエストごとの小さなキャッシュがこれを解決します。DB の呼び出しを小さなサービスで包みます。

```php
final class SlugProvider
{
	/** @var array<int, string> */
	private array $cache = [];

	public function __construct(
		private Nette\Database\Explorer $db,
	) {
	}

	public function getSlug(int $id): string
	{
		return $this->cache[$id] ??= Strings::webalize(Strings::truncate(
			(string) $this->db->fetchField('SELECT title FROM article WHERE id = ?', $id),
			100, ''
		));
	}
}
```

これで十分です。リクエストごと、一意な ID ごとに DB へのアクセスは 1 回です。


テンプレートからタイトルを渡す(任意の近道)
========================

タイトルがすでにテンプレートの手元にあるなら、DB の検索をまるごと省けます。タイトルを名前付きパラメータとして渡します。

```latte
<a n:href="Article:detail, $article->id, slug => $article->title">{$article->title}</a>
```

…そして、そのタイトルを URL に安全な文字列に変えるパラメータごとの `FilterOut` を足します。

```php
$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'slug' => [
		Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
	],
	'' => [/* 上で作った検索のフォールバック */],
]);
```

2 つのフィルタは協調します。まず一般フィルタが走り、スラッグがすでに渡されたタイトルで埋まっているのを見て DB の検索を飛ばします。次にパラメータごとの `FilterOut` が、そのタイトルをきちんとしたスラッグに変えます。タイトルを渡さないテンプレートもそのまま動きます。一般フィルタがスラッグの空を見て、検索の経路を通るからです。

これは効果のある場所(1 リクエストで何百回も描かれる大きな一覧)でだけ使ってください。アプリケーションのほとんどの場所では、キャッシュ付きの検索で十分に速いです。


正規化: 正しい URL へのリダイレクト
========================

これで `/article/123-how-to-bake-bread` を生成できるようになりましたが、ルートは相変わらず `/article/123` や `/article/123-anything-someone-wrote` も受け付けます。これは意図的です。短い URL がほしく(後述します)、古いリンクや手打ちのリンクも動き続けてほしいからです。しかし検索エンジンに、同じ記事を複数のアドレスで登録されたくはありません。

その答えが[正規化 |application:presenters#正規化]です。ユーザーが正規でない URL でやってきたら、アプリケーションが 301 で正しい URL にリダイレクトします。`canonicalize()` メソッドがこれを担当します。

```php
public function actionDetail(int $id, ?string $slug = null): void
{
	$article = $this->facade->getArticle($id);
	if (!$article) {
		$this->error();
	}

	// 同じ FilterOut を通して正規の URL を生成し、
	// 現在の URL と違えば HTTP 301 でリダイレクトします
	$this->canonicalize('detail', ['id' => $id]);

	$this->template->article = $article;
}
```

`canonicalize()` は `link()` と同じやり方で正規の URL を生成し(つまり同じ `FilterOut` を通ります)、現在の URL と比べます。違えば HTTP 301 でリダイレクトします。訪問者は正しい URL にたどり着き、検索エンジンは正規の版だけを見ます。


スラッグの見た目を決める場所はひとつ
==================

`Strings::webalize(Strings::truncate(..., 100, ''))` の呼び出しがただ 1 か所、`SlugProvider`(あるいはパラメータごとの `FilterOut`)の中にあることに注目してください。同じロジックが、テンプレートのリンク、`redirect()` の URL、`canonicalize()` の正規形を生みます。

あとから規則を変えたくなったら(長さの上限を変える、翻字を変える、余分な文字を落とす)、1 行を変えるだけです。そうしていなければ、`redirect()` が `/article/123-how-to-bake-bread` を生む一方で `canonicalize()` は `/article/123-how-to-bake-bre` を期待し(どこかで別の `truncate` の長さが使われたせいで)、アプリケーションがリダイレクトのループに陥る危険があります。


おまけ: 短い URL も動き続ける
====================

スラッグは省略可能なので、スラッグのないアドレスも動きます。

```
/article/123
```

これは次のような場面で役立ちます。
- **QR コード** - URL が短いほどコードが粗くなり、読み取りやすくなります
- **SMS やチャット** - ツイートに収まり、見た目もすっきりします
- **印刷物** - 短い URL は打ち込むのが速いです

ユーザーがそうした URL を開くと、`canonicalize()` がスラッグ付きの完全な版に 301 でリダイレクトするので、検索エンジンは正規形だけを見ます。短さと SEO を同時に手に入れられます。


まとめ
===

- マスク `<id>[-<slug>]` でスラッグを省略可能にします。既定の `<slug>` は `/` に一致しません。スラッグにスラッシュを入れたいときだけ `<slug .+>` を使ってください。
- `''` のキーの下の一般 `FilterOut` が ID からタイトルを調べます。**アプリケーションのどこでもテンプレートを変える必要はありません**。
- 検索はリクエストごとの小さなキャッシュで包みましょう。一意な ID ごとに DB のクエリ 1 回で十分です。
- 必要ならパラメータごとの `FilterOut` で、テンプレートからタイトルを直接渡して検索を飛ばせます。
- アクションの中の `$this->canonicalize()` が、正規でない URL を HTTP 301 で正しい URL にリダイレクトします。
- スラッグの作り方(`webalize` + `truncate`)は 1 か所にあります。一度変えれば、どこにでも効きます。
- ID だけの短い URL も動き続けるので、QR コードや SMS に便利です。

フィルタと正規化については、[ルーティング |application:routing#一般のフィルタ]と[プレゼンター |application:presenters#正規化]のドキュメントで詳しく扱っています。

スラッグを使った読みやすい URL

/article/123-how-to-bake-bread のような URL は /article/123 より見栄えがよく、ユーザーにも検索エンジンにもページの内容を伝えてくれます。このガイドでは、テンプレートに一切手を触れずにルーターだけでそれを生成する方法と、すべての訪問者が正規の URL にたどり着くようにする方法を紹介します。

なぜ URL にスラッグを入れるのか

2 つのアドレスを比べてみましょう。

/article/123
/article/123-how-to-bake-bread

2 つめは、クリックした先に何があるのかをユーザー(そして Google)に伝えます。SEO に良く、チャットやメールの中でリンクが読みやすくなり、アドレスバーにも意味が生まれます。

ただしスラッグは本当の識別子ではありません。ページを決めるのは ID です。スラッグはアプリケーションがタイトルから作る飾りです。タイトルが変われば、スラッグも変わるべきです。そして誰かが URL を手で書き換えたり、古いリンクをたどったりしても、アプリケーションは正しいページを見つけられるべきです。

目標

次のすべてを扱えるルートがほしいとします。

/article/123                              → 記事 123 を開き、正規の URL にリダイレクト
/article/123-how-to-bake-bread            → 記事 123 を直接開く
/article/123-anything-someone-typed       → 記事 123 を開き、正規の URL にリダイレクト
/article/                                 → 404(ID がない)

そしてアプリケーション全体のすべての n:hreflink() の呼び出しが、テンプレートを 1 行も書き換えずに自動的に /article/123-how-to-bake-bread を生むようにしたいのです。

ルートのマスク

仕掛けは、角かっこを使ってマスクの中でスラッグを省略可能にすることです。

$router->addRoute('article/<id [0-9]+>[-<slug>]', 'Article:detail');

マスク [-<slug>] は「ID のあとにハイフンとスラッグが続くかもしれないが、必須ではない」という意味です。このルートは /article/123/article/123-anything も受け付けます。

パラメータ <slug> について一言。既定ではスラッシュ以外の任意の文字に一致します。まさに望みどおりです。<slug .+> と書くとスラッシュにも一致するので、/article/123-something/else/ を含むひとつのスラッグとして解析されてしまいます。本当に必要でない限り、既定の <slug> のままにしてください。

ここまでで URL は正しく解析されますが、生成されるリンクにはスラッグが入りません。次はスラッグを埋める方法をルートに教えます。

テンプレートに触れずにスラッグを生成する

これが決め手となるやり方です。既存の n:href="Article:detail, $id" の呼び出しは、アプリケーション全体でそのまま動き続けます。ルーターが自分でタイトルを調べてくれるのです。

そのために、空文字列のキーの下に一般フィルタを置きます。すべてのパラメータを一度に見られるので、スラッグを足せます。

use Nette\Routing\Route;
use Nette\Utils\Strings;

$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'' => [
		Route::FilterOut => function (array $params) use ($slugProvider): array {
			if (isset($params['id']) && empty($params['slug'])) {
				$params['slug'] = $slugProvider->getSlug((int) $params['id']);
			}
			return $params;
		},
	],
]);

FilterOut は、ルーターが URL を生成するたびに走ります。スラッグが渡されていなければ、フィルタがタイトルを調べて足します。

たったひとつのルート定義を変えるだけで、アプリケーション全体にスラッグを導入できます。あらゆるテンプレートのあらゆるリンクが、自動的に /article/123-how-to-bake-bread を出すようになります。grep も、テンプレート探しも、見落としもありません。

検索結果をキャッシュする

リンク 1 本につき DB のクエリが 1 回走りますが、ふつうのページにはリンクがたくさんあります。一覧、パンくず、「最近見たもの」、関連記事などです。ひとつのリクエストの中で同じ記事 ID が複数のリンクに現れることも多く、そのたびにデータベースを叩きたくはありません。

リクエストごとの小さなキャッシュがこれを解決します。DB の呼び出しを小さなサービスで包みます。

final class SlugProvider
{
	/** @var array<int, string> */
	private array $cache = [];

	public function __construct(
		private Nette\Database\Explorer $db,
	) {
	}

	public function getSlug(int $id): string
	{
		return $this->cache[$id] ??= Strings::webalize(Strings::truncate(
			(string) $this->db->fetchField('SELECT title FROM article WHERE id = ?', $id),
			100, ''
		));
	}
}

これで十分です。リクエストごと、一意な ID ごとに DB へのアクセスは 1 回です。

テンプレートからタイトルを渡す(任意の近道)

タイトルがすでにテンプレートの手元にあるなら、DB の検索をまるごと省けます。タイトルを名前付きパラメータとして渡します。

<a n:href="Article:detail, $article->id, slug => $article->title">{$article->title}</a>

…そして、そのタイトルを URL に安全な文字列に変えるパラメータごとの FilterOut を足します。

$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'slug' => [
		Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
	],
	'' => [/* 上で作った検索のフォールバック */],
]);

2 つのフィルタは協調します。まず一般フィルタが走り、スラッグがすでに渡されたタイトルで埋まっているのを見て DB の検索を飛ばします。次にパラメータごとの FilterOut が、そのタイトルをきちんとしたスラッグに変えます。タイトルを渡さないテンプレートもそのまま動きます。一般フィルタがスラッグの空を見て、検索の経路を通るからです。

これは効果のある場所(1 リクエストで何百回も描かれる大きな一覧)でだけ使ってください。アプリケーションのほとんどの場所では、キャッシュ付きの検索で十分に速いです。

正規化: 正しい URL へのリダイレクト

これで /article/123-how-to-bake-bread を生成できるようになりましたが、ルートは相変わらず /article/123/article/123-anything-someone-wrote も受け付けます。これは意図的です。短い URL がほしく(後述します)、古いリンクや手打ちのリンクも動き続けてほしいからです。しかし検索エンジンに、同じ記事を複数のアドレスで登録されたくはありません。

その答えが正規化です。ユーザーが正規でない URL でやってきたら、アプリケーションが 301 で正しい URL にリダイレクトします。canonicalize() メソッドがこれを担当します。

public function actionDetail(int $id, ?string $slug = null): void
{
	$article = $this->facade->getArticle($id);
	if (!$article) {
		$this->error();
	}

	// 同じ FilterOut を通して正規の URL を生成し、
	// 現在の URL と違えば HTTP 301 でリダイレクトします
	$this->canonicalize('detail', ['id' => $id]);

	$this->template->article = $article;
}

canonicalize()link() と同じやり方で正規の URL を生成し(つまり同じ FilterOut を通ります)、現在の URL と比べます。違えば HTTP 301 でリダイレクトします。訪問者は正しい URL にたどり着き、検索エンジンは正規の版だけを見ます。

スラッグの見た目を決める場所はひとつ

Strings::webalize(Strings::truncate(..., 100, '')) の呼び出しがただ 1 か所、SlugProvider(あるいはパラメータごとの FilterOut)の中にあることに注目してください。同じロジックが、テンプレートのリンク、redirect() の URL、canonicalize() の正規形を生みます。

あとから規則を変えたくなったら(長さの上限を変える、翻字を変える、余分な文字を落とす)、1 行を変えるだけです。そうしていなければ、redirect()/article/123-how-to-bake-bread を生む一方で canonicalize()/article/123-how-to-bake-bre を期待し(どこかで別の truncate の長さが使われたせいで)、アプリケーションがリダイレクトのループに陥る危険があります。

おまけ: 短い URL も動き続ける

スラッグは省略可能なので、スラッグのないアドレスも動きます。

/article/123

これは次のような場面で役立ちます。

  • QR コード – URL が短いほどコードが粗くなり、読み取りやすくなります
  • SMS やチャット – ツイートに収まり、見た目もすっきりします
  • 印刷物 – 短い URL は打ち込むのが速いです

ユーザーがそうした URL を開くと、canonicalize() がスラッグ付きの完全な版に 301 でリダイレクトするので、検索エンジンは正規形だけを見ます。短さと SEO を同時に手に入れられます。

まとめ

  • マスク <id>[-<slug>] でスラッグを省略可能にします。既定の <slug>/ に一致しません。スラッグにスラッシュを入れたいときだけ <slug .+> を使ってください。
  • '' のキーの下の一般 FilterOut が ID からタイトルを調べます。アプリケーションのどこでもテンプレートを変える必要はありません
  • 検索はリクエストごとの小さなキャッシュで包みましょう。一意な ID ごとに DB のクエリ 1 回で十分です。
  • 必要ならパラメータごとの FilterOut で、テンプレートからタイトルを直接渡して検索を飛ばせます。
  • アクションの中の $this->canonicalize() が、正規でない URL を HTTP 301 で正しい URL にリダイレクトします。
  • スラッグの作り方(webalize + truncate)は 1 か所にあります。一度変えれば、どこにでも効きます。
  • ID だけの短い URL も動き続けるので、QR コードや SMS に便利です。

フィルタと正規化については、ルーティングプレゼンターのドキュメントで詳しく扱っています。