Przyjazne adresy URL ze slugami

URL takie jak /artykul/123-jak-upiec-chleb wygląda lepiej niż /artykul/123 i pomaga zarówno użytkownikom, jak i wyszukiwarkom zrozumieć, co czeka na stronie. Ten poradnik pokazuje, jak generować je wyłącznie w routerze, bez ingerencji w ani jeden szablon, i jak zadbać o to, żeby każdy odwiedzający wylądował na kanonicznym URL.

Dlaczego slug w URL

Porównaj te dwa adresy:

/artykul/123
/artykul/123-jak-upiec-chleb

Drugi zdradza użytkownikowi (i Google'owi), co czeka go po kliknięciu. To dobre dla SEO, sprawia, że odnośniki są czytelne w czacie albo e-mailu, i nadaje sens także paskowi adresu.

Slug nie jest jednak prawdziwym identyfikatorem. Stronę określa ID. Slug to tylko dekoracja, którą aplikacja generuje z tytułu. Gdy tytuł się zmieni, slug też powinien się zmienić. A gdy ktoś ręcznie zmodyfikuje URL albo trafi ze starego odnośnika, aplikacja i tak powinna znaleźć właściwą stronę.

Cel

Chcemy trasę, która obsłuży to wszystko:

/artykul/123                              → otwiera artykuł 123, przekierowuje na kanoniczny URL
/artykul/123-jak-upiec-chleb              → otwiera artykuł 123 bezpośrednio
/artykul/123-cokolwiek-ktos-wpisal        → otwiera artykuł 123, przekierowuje na kanoniczny URL
/artykul/                                 → 404 (brak ID)

I chcemy, żeby każde n:href i każde wywołanie link() w całej aplikacji automatycznie tworzyło /artykul/123-jak-upiec-chleb, bez przepisywania ani jednego szablonu.

Maska trasy

Sztuczka polega na oznaczeniu sluga w masce jako opcjonalnego za pomocą nawiasów kwadratowych:

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

Maska [-<slug>] mówi: po ID może być myślnik i slug, ale nie musi. Trasa akceptuje zarówno /artykul/123, jak i /artykul/123-cokolwiek.

Uwaga do parametru <slug>: domyślnie dopasowuje dowolne znaki z wyjątkiem ukośnika, czyli dokładnie to, czego chcemy. Jeśli napiszesz <slug .+>, parametr będzie dopasowywał także ukośniki, więc /artykul/123-cos/jeszcze zostanie sparsowane jako jeden slug zawierający /. Zostań przy domyślnym <slug>, chyba że naprawdę tego potrzebujesz.

Na razie URL jest parsowany poprawnie, ale generowane odnośniki nie będą zawierać sluga. Kolejnym krokiem jest nauczenie trasy, jak sluga uzupełnić.

Generowanie sluga bez ingerencji w szablony

To jest ten zabójczy wariant. Istniejące wywołania n:href="Article:detail, $id" działają dalej bez zmian w całej aplikacji, bo router sam wyszuka tytuł.

Zrobimy to za pomocą ogólnego filtra pod kluczem pustego ciągu: widzi on wszystkie parametry naraz i może dodać sluga:

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

$router->addRoute('artykul/<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 uruchamia się za każdym razem, gdy router generuje URL. Jeśli slug nie został przekazany, filtr wyszuka tytuł i go doda.

Slugi możesz wdrożyć w całej aplikacji jedną zmianą: wystarczy jedna definicja trasy. Każdy odnośnik w każdym szablonie zacznie automatycznie tworzyć /artykul/123-jak-upiec-chleb. Bez grepowania, bez przeszukiwania szablonów, bez przeoczonych zakamarków.

Zapamiętaj wynik wyszukiwania

Jeden odnośnik generuje jedno zapytanie do bazy, a typowa strona ma ich wiele: listingi, okruszki, “ostatnio oglądane”, artykuły powiązane. To samo ID artykułu pojawia się często w kilku odnośnikach w ramach jednego żądania, a nie chcesz odpytywać bazy za każdym razem.

Rozwiązuje to maleńki cache w obrębie żądania. Zamknij wywołanie bazy w małej usłudze:

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, ''
		));
	}
}

To wystarczy: jedno odpytanie bazy na unikalne ID w ramach żądania.

Przekazanie tytułu z szablonu (opcjonalna szybka ścieżka)

Gdy tytuł jest w szablonie już pod ręką, możesz wyszukiwanie w bazie całkowicie pominąć. Przekaż tytuł jako parametr nazwany:

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

…i dodaj FilterOut dla pojedynczego parametru, który zamieni tytuł na ciąg bezpieczny dla URL:

$router->addRoute('artykul/<id [0-9]+>[-<slug>]', [
	'presenter' => 'Article',
	'action' => 'detail',
	'slug' => [
		Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
	],
	'' => [/* fallback z wyszukiwaniem z góry */],
]);

Oba filtry współpracują. Najpierw uruchamia się filtr ogólny; widząc, że slug jest już wypełniony przekazanym tytułem, pomija odpytanie bazy. Filtr FilterOut przy parametrze zamienia potem ten tytuł na właściwego sluga. Szablony, które tytułu nie przekazują, działają dalej: filtr ogólny znajdzie pustego sluga i pójdzie ścieżką z wyszukiwaniem.

Używaj tego tylko tam, gdzie ma to znaczenie (duże listingi renderowane setki razy na żądanie). Dla większości aplikacji wyszukiwanie z cache jest wystarczająco szybkie.

Kanonizacja: przekierowanie na właściwy URL

Potrafimy już generować /artykul/123-jak-upiec-chleb, ale trasa nadal akceptuje /artykul/123 oraz /artykul/123-cokolwiek-ktos-napisal. To zamierzone: chcemy krótkich URL-i (więcej o tym niżej) i chcemy, żeby stare albo ręcznie wpisane odnośniki dalej działały. Nie chcemy jednak, żeby wyszukiwarki indeksowały ten sam artykuł pod kilkoma adresami.

Rozwiązaniem jest kanonizacja: gdy użytkownik przyjdzie przez niekanoniczny URL, aplikacja przekieruje go kodem 301 na właściwy. Zajmuje się tym metoda canonicalize():

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

	// generuje kanoniczny URL przez ten sam FilterOut
	// i przekierowuje z HTTP 301, jeśli różni się od bieżącego URL
	$this->canonicalize('detail', ['id' => $id]);

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

canonicalize() generuje kanoniczny URL tak samo, jak zrobiłoby to link() (czyli przechodzi przez ten sam FilterOut), i porównuje go z bieżącym URL. Jeśli się różnią, przekierowuje z HTTP 301. Odwiedzający lądują na właściwym URL, a wyszukiwarki widzą tylko jedną kanoniczną wersję.

Jedno miejsce decydujące o wyglądzie sluga

Zauważ, że wywołanie Strings::webalize(Strings::truncate(..., 100, '')) żyje w jednym miejscu: wewnątrz SlugProvider (albo w FilterOut przy parametrze). Ta sama logika tworzy odnośnik w szablonie, URL w redirect() i postać kanoniczną w canonicalize().

Jeśli zechcesz później zmienić reguły (inny limit długości, inna transliteracja, usuwanie dodatkowych znaków), zmieniasz jedną linię. Bez tego ryzykowałbyś, że redirect() wygeneruje /artykul/123-jak-upiec-chleb, podczas gdy canonicalize() oczekuje /artykul/123-jak-upiec-chl (bo ktoś gdzie indziej zastosował inną długość truncate), a aplikacja przekierowywałaby w kółko.

Bonus: krótkie URL-e nadal działają

Ponieważ slug jest opcjonalny, adresy bez niego dalej działają:

/artykul/123

Przydaje się to przy:

  • kodach QR – krótszy URL oznacza mniej gęsty, łatwiejszy do zeskanowania kod
  • SMS-ach i czacie – mieści się w tweecie, wygląda schludnie
  • materiałach drukowanych – krótki URL szybciej się przepisuje

Gdy użytkownik otworzy taki URL, canonicalize() przekieruje go kodem 301 na pełną wersję ze slugiem, więc wyszukiwarki i tak zobaczą tylko postać kanoniczną. Możesz mieć jednocześnie zwięzłość i SEO.

Podsumowanie

  • Maska <id>[-<slug>] czyni sluga opcjonalnym. Domyślne <slug> nie dopasowuje /; użyj <slug .+> tylko wtedy, gdy naprawdę chcesz ukośników w slugu.
  • Ogólny FilterOut pod kluczem '' wyszukuje tytuł po ID – bez zmian w szablonach w całej aplikacji.
  • Zamknij wyszukiwanie w maleńkim cache w obrębie żądania; jedno zapytanie do bazy na unikalne ID w zupełności wystarczy.
  • Opcjonalnie FilterOut przy parametrze pozwala szablonom przekazać tytuł bezpośrednio i pominąć wyszukiwanie.
  • $this->canonicalize() w akcji przekierowuje niekanoniczne URL-e na właściwy z HTTP 301.
  • Wzór na sluga (webalize + truncate) żyje w jednym miejscu – zmienisz go raz, zadziała wszędzie.
  • Krótkie URL-e z samym ID działają dalej, co przydaje się przy kodach QR i SMS-ach.

Więcej o filtrach i kanonizacji znajdziesz w dokumentacji routingupresenterów.