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