Nette Documentation Preview

syntax
Красивые URL со слагами
***********************

.[perex]
URL вида `/article/123-how-to-bake-bread` выглядят лучше, чем `/article/123`, и помогают и пользователям, и поисковым системам понять, что на странице. Это руководство показывает, как формировать их целиком в маршрутизаторе, не трогая ни одного шаблона, и как позаботиться о том, чтобы каждый посетитель попадал на канонический URL.


Зачем слаги в URL
=================

Сравните два адреса:

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

Второй сообщает пользователю (и 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()` во всём приложении автоматически порождал `/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, никакой охоты по шаблонам, никаких пропущенных углов.


Кешируйте поиск
===============

Одна ссылка порождает один запрос к базе, но на типичной странице их много: списки, хлебные крошки, "последние просмотренные", похожие статьи. Один и тот же идентификатор статьи часто встречается в нескольких ссылках в пределах одного запроса, и обращаться к базе каждый раз не хочется.

Небольшой кеш в пределах запроса решает задачу. Оберните обращение к базе в маленький сервис:

```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 в пределах запроса.


Передача заголовка из шаблона (необязательный быстрый путь)
===========================================================

Когда заголовок уже под рукой в шаблоне, обращение к базе можно вообще пропустить. Передайте заголовок именованным параметром:

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

…и добавьте `FilterOut` для отдельного параметра, который превращает заголовок в строку, пригодную для URL:

```php
$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'slug' => [
		Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
	],
	'' => [/* запасной поиск из примера выше */],
]);
```

Два фильтра работают вместе. Общий фильтр выполняется первым; видя, что слаг уже заполнен переданным заголовком, он пропускает обращение к базе. Затем `FilterOut` отдельного параметра превращает этот заголовок в полноценный слаг. Шаблоны, которые заголовок не передают, тоже работают: общий фильтр видит пустой слаг и идёт путём поиска.

Используйте это только там, где это важно (большие списки, отрисовываемые сотнями за запрос). Для большей части приложения кешированного поиска достаточно.


Канонизация: перенаправление на правильный URL
==============================================

Теперь мы умеем порождать `/article/123-how-to-bake-bread`, но маршрут по-прежнему принимает `/article/123` и `/article/123-anything-someone-wrote`. Это сделано намеренно: нам нужны короткие URL (подробнее ниже) и нужно, чтобы старые или набранные вручную ссылки продолжали работать. Но нам не нужно, чтобы поисковые системы индексировали одну и ту же статью по нескольким адресам.

Решение - [канонизация |application:presenters#Канонизация]: когда пользователь приходит по неканоническому URL, приложение перенаправляет его на правильный с кодом 301. Этим занимается метод `canonicalize()`:

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

	// порождает канонический URL через тот же FilterOut
	// и перенаправляет с HTTP 301, если он отличается от текущего URL
	$this->canonicalize('detail', ['id' => $id]);

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

`canonicalize()` порождает канонический URL так же, как это сделал бы `link()` (то есть проходит через тот же `FilterOut`), и сравнивает его с текущим URL. Если они различаются, происходит перенаправление с HTTP 301. Посетители попадают на правильный URL, а поисковые системы видят только одну каноническую версию.


Одно место, определяющее вид слага
==================================

Обратите внимание, что вызов `Strings::webalize(Strings::truncate(..., 100, ''))` живёт в одном месте - внутри `SlugProvider` (или в `FilterOut` отдельного параметра). Одна и та же логика порождает ссылку в шаблоне, URL в `redirect()` и каноническую форму в `canonicalize()`.

Если позже вы захотите изменить правила (другой предел длины, другая транслитерация, удаление лишних символов), вы поменяете одну строку. Иначе вы рисковали бы тем, что `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 вполне достаточно.
- При желании `FilterOut` отдельного параметра позволяет шаблонам передавать заголовок напрямую и пропускать поиск.
- `$this->canonicalize()` в действии перенаправляет неканонические URL на правильный с кодом HTTP 301.
- Формула слага (`webalize` + `truncate`) живёт в одном месте: измените её однажды, и это подействует везде.
- Короткие URL только с ID продолжают работать, что удобно для QR-кодов и SMS.

Подробнее о фильтрах и канонизации вы найдёте в документации по [маршрутизации |application:routing#Общие фильтры] и [презентерам |application:presenters#Канонизация].

Красивые URL со слагами

URL вида /article/123-how-to-bake-bread выглядят лучше, чем /article/123, и помогают и пользователям, и поисковым системам понять, что на странице. Это руководство показывает, как формировать их целиком в маршрутизаторе, не трогая ни одного шаблона, и как позаботиться о том, чтобы каждый посетитель попадал на канонический URL.

Зачем слаги в URL

Сравните два адреса:

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

Второй сообщает пользователю (и 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() во всём приложении автоматически порождал /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, никакой охоты по шаблонам, никаких пропущенных углов.

Кешируйте поиск

Одна ссылка порождает один запрос к базе, но на типичной странице их много: списки, хлебные крошки, „последние просмотренные“, похожие статьи. Один и тот же идентификатор статьи часто встречается в нескольких ссылках в пределах одного запроса, и обращаться к базе каждый раз не хочется.

Небольшой кеш в пределах запроса решает задачу. Оберните обращение к базе в маленький сервис:

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 в пределах запроса.

Передача заголовка из шаблона (необязательный быстрый путь)

Когда заголовок уже под рукой в шаблоне, обращение к базе можно вообще пропустить. Передайте заголовок именованным параметром:

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

…и добавьте FilterOut для отдельного параметра, который превращает заголовок в строку, пригодную для URL:

$router->addRoute('article/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'slug' => [
		Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
	],
	'' => [/* запасной поиск из примера выше */],
]);

Два фильтра работают вместе. Общий фильтр выполняется первым; видя, что слаг уже заполнен переданным заголовком, он пропускает обращение к базе. Затем FilterOut отдельного параметра превращает этот заголовок в полноценный слаг. Шаблоны, которые заголовок не передают, тоже работают: общий фильтр видит пустой слаг и идёт путём поиска.

Используйте это только там, где это важно (большие списки, отрисовываемые сотнями за запрос). Для большей части приложения кешированного поиска достаточно.

Канонизация: перенаправление на правильный URL

Теперь мы умеем порождать /article/123-how-to-bake-bread, но маршрут по-прежнему принимает /article/123 и /article/123-anything-someone-wrote. Это сделано намеренно: нам нужны короткие URL (подробнее ниже) и нужно, чтобы старые или набранные вручную ссылки продолжали работать. Но нам не нужно, чтобы поисковые системы индексировали одну и ту же статью по нескольким адресам.

Решение – канонизация: когда пользователь приходит по неканоническому URL, приложение перенаправляет его на правильный с кодом 301. Этим занимается метод canonicalize():

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

	// порождает канонический URL через тот же FilterOut
	// и перенаправляет с HTTP 301, если он отличается от текущего URL
	$this->canonicalize('detail', ['id' => $id]);

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

canonicalize() порождает канонический URL так же, как это сделал бы link() (то есть проходит через тот же FilterOut), и сравнивает его с текущим URL. Если они различаются, происходит перенаправление с HTTP 301. Посетители попадают на правильный URL, а поисковые системы видят только одну каноническую версию.

Одно место, определяющее вид слага

Обратите внимание, что вызов Strings::webalize(Strings::truncate(..., 100, '')) живёт в одном месте – внутри SlugProvider (или в FilterOut отдельного параметра). Одна и та же логика порождает ссылку в шаблоне, URL в redirect() и каноническую форму в canonicalize().

Если позже вы захотите изменить правила (другой предел длины, другая транслитерация, удаление лишних символов), вы поменяете одну строку. Иначе вы рисковали бы тем, что 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 вполне достаточно.
  • При желании FilterOut отдельного параметра позволяет шаблонам передавать заголовок напрямую и пропускать поиск.
  • $this->canonicalize() в действии перенаправляет неканонические URL на правильный с кодом HTTP 301.
  • Формула слага (webalize + truncate) живёт в одном месте: измените её однажды, и это подействует везде.
  • Короткие URL только с ID продолжают работать, что удобно для QR-кодов и SMS.

Подробнее о фильтрах и канонизации вы найдёте в документации по маршрутизации и презентерам.