Красивые 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.
Подробнее о фильтрах и канонизации вы найдёте в документации по маршрутизации и презентерам.