Skip to content

Commit 2770467

Browse files
committed
best-practices: add 'Pretty URLs with Slugs' guide
1 parent 2c815d9 commit 2770467

8 files changed

Lines changed: 418 additions & 0 deletions

File tree

application/cs/presenters.texy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ public function actionShow(int $id, ?string $slug = null): void
393393
}
394394
```
395395

396+
Kompletní vzor, který kombinuje routovací filtry s `canonicalize()` pro generování SEO-friendly URL, najdete v návodu [Hezké URL se slugem |best-practices:pretty-urls].
397+
396398

397399
Události
398400
--------

application/cs/routing.texy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ Obecné filtry dávají možnost upravit chování routy naprosto jakýmkoliv zp
325325

326326
Pokud má parametr definovaný vlastní filtr a současně existuje obecný filtr, provede se vlastní `FilterIn` před obecným a naopak obecný `FilterOut` před vlastním. Tedy uvnitř obecného filtru jsou hodnoty parametrů `presenter` resp. `action` zapsané ve stylu PascalCase resp. camelCase.
327327

328+
Praktické využití těchto filtrů — generování SEO-friendly URL typu `/clanek/123-jak-upect-chleba` bez zásahu do šablon — najdete v návodu [Hezké URL se slugem |best-practices:pretty-urls].
329+
328330

329331
Jednosměrky OneWay
330332
------------------

application/en/presenters.texy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ public function actionShow(int $id, ?string $slug = null): void
393393
}
394394
```
395395

396+
For a complete pattern that combines route filters with `canonicalize()` to produce SEO-friendly URLs, see [Pretty URLs with Slugs |best-practices:pretty-urls].
397+
396398

397399
Events
398400
------

application/en/routing.texy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ General filters provide the ability to modify the route's behavior in absolutely
325325

326326
If a parameter has its own filter defined and a general filter also exists, the custom `FilterIn` is executed before the general one, and conversely, the general `FilterOut` is executed before the custom one. Thus, inside the general filter, the values of the parameters `presenter` and `action` are written in PascalCase or camelCase style, respectively.
327327

328+
See [Pretty URLs with Slugs |best-practices:pretty-urls] for a practical use of these filters — generating SEO-friendly URLs like `/article/123-how-to-bake-bread` without modifying any templates.
329+
328330

329331
OneWay Flag
330332
-----------

best-practices/cs/@home.texy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Nette Aplikace
1919
- [Dynamické snippety |dynamic-snippets]
2020
- [Jak používat atribut #Requires |attribute-requires]
2121
- [Jak správně používat POST odkazy |post-links]
22+
- [Hezké URL se slugem |pretty-urls]
2223

2324
</div>
2425
<div>

best-practices/cs/pretty-urls.texy

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
Hezké URL se slugem
2+
*******************
3+
4+
.[perex]
5+
URL jako `/clanek/123-jak-upect-chleba` vypadá lépe než `/clanek/123` a pomáhá uživatelům i vyhledávačům pochopit, co na stránce čeká. Tento návod ukazuje, jak je generovat čistě v routeru — bez zásahu do jediné šablony — a jak zařídit, aby každý návštěvník skončil na kanonické URL.
6+
7+
8+
Proč slug v URL
9+
===============
10+
11+
Porovnejte tyto dvě adresy:
12+
13+
```
14+
/clanek/123
15+
/clanek/123-jak-upect-chleba
16+
```
17+
18+
Druhá uživateli (a Googlu) prozradí, co ho po kliknutí čeká. To je dobré pro SEO, dělá odkazy čitelné v chatu nebo e-mailu a dá smysl i URL liště.
19+
20+
Slug ale není skutečný identifikátor. Stránku určuje ID. Slug je jen dekorace, kterou aplikace generuje z titulku. Když se titulek změní, slug by se měl změnit taky. A když někdo URL ručně upraví nebo přijde po starém odkazu, aplikace by stejně měla najít správnou stránku.
21+
22+
23+
Cíl
24+
===
25+
26+
Chceme routu, která zvládne všechny tyto případy:
27+
28+
```
29+
/clanek/123 → otevře článek 123, přesměruje na kanonickou URL
30+
/clanek/123-jak-upect-chleba → otevře článek 123 přímo
31+
/clanek/123-cokoli-co-nekdo-napsal → otevře článek 123, přesměruje na kanonickou URL
32+
/clanek/ → 404 (chybí ID)
33+
```
34+
35+
A chceme, aby každé `n:href` a `link()` napříč aplikací automaticky vyrobilo `/clanek/123-jak-upect-chleba` — **bez přepisování jediné šablony**.
36+
37+
38+
Maska routy
39+
===========
40+
41+
Trik spočívá v označení slugu v masce jako **nepovinného** pomocí hranatých závorek:
42+
43+
```php
44+
$router->addRoute('clanek/<id [0-9]+>[-<slug>]', 'Article:detail');
45+
```
46+
47+
Maska `[-<slug>]` říká: po ID může (ale nemusí) následovat pomlčka a slug. Routa přijímá `/clanek/123` i `/clanek/123-cokoli`.
48+
49+
Poznámka k parametru `<slug>`: defaultně matchuje libovolné znaky **kromě lomítka** — přesně to, co chceme. Pokud napíšete `<slug .+>`, parametr bude matchovat i lomítka, takže `/clanek/123-neco/jineho` by se naparsovalo jako jediný slug obsahující `/`. Pokud nechcete lomítka ve slugu, zůstaňte u defaultního `<slug>`.
50+
51+
URL se teď parsuje správně, ale generované odkazy slug neobsahují. Dalším krokem je routu naučit, jak slug doplnit.
52+
53+
54+
Generování slugu bez zásahu do šablon
55+
=====================================
56+
57+
Tohle je hlavní varianta. Stávající `n:href="Article:detail, $id"` volání zůstávají beze změny napříč celou aplikací — router si titulek vyhledá sám.
58+
59+
Použijeme **obecný filtr** pod klíčem prázdného stringu — ten vidí všechny parametry najednou a může slug doplnit:
60+
61+
```php
62+
use Nette\Routing\Route;
63+
use Nette\Utils\Strings;
64+
65+
$router->addRoute('clanek/<id [0-9]+>[-<slug>]', [
66+
'presenter' => 'Article',
67+
'action' => 'detail',
68+
'' => [
69+
Route::FilterOut => function (array $params) use ($slugProvider): array {
70+
if (isset($params['id']) && empty($params['slug'])) {
71+
$params['slug'] = $slugProvider->getSlug((int) $params['id']);
72+
}
73+
return $params;
74+
},
75+
],
76+
]);
77+
```
78+
79+
`FilterOut` se spustí pokaždé, když router **generuje** URL. Pokud slug nebyl předán, filtr titulek dohledá a doplní.
80+
81+
Slugy můžete nasadit napříč celou aplikací jedinou změnou — jednou definicí routy. Každý odkaz v každé šabloně začne automaticky produkovat `/clanek/123-jak-upect-chleba`. Žádný grep, žádné hledání po šablonách, žádný přehlédnutý case.
82+
83+
84+
Cache pro vyhledávání
85+
=====================
86+
87+
Jedno volání odkazu znamená jeden DB dotaz, ale typická stránka jich má hodně — výpisy, drobečková navigace, „naposledy prohlížené", související články. Stejné ID článku se v rámci jednoho requestu objeví v několika odkazech a nechceme do DB chodit pokaždé.
88+
89+
Stačí drobná per-request cache. Obalte DB volání malou službou:
90+
91+
```php
92+
final class SlugProvider
93+
{
94+
/** @var array<int, string> */
95+
private array $cache = [];
96+
97+
public function __construct(
98+
private Nette\Database\Explorer $db,
99+
) {
100+
}
101+
102+
public function getSlug(int $id): string
103+
{
104+
return $this->cache[$id] ??= Strings::webalize(Strings::truncate(
105+
(string) $this->db->fetchField('SELECT title FROM article WHERE id = ?', $id),
106+
100, ''
107+
));
108+
}
109+
}
110+
```
111+
112+
To stačí — jeden DB dotaz na unikátní ID za request.
113+
114+
115+
Předání titulku ze šablony (volitelná rychlá cesta)
116+
===================================================
117+
118+
Pokud máte titulek v šabloně po ruce, můžete se DB dotazu úplně vyhnout. Předejte titulek jako pojmenovaný parametr:
119+
120+
```latte
121+
<a n:href="Article:detail, $article->id, slug => $article->title">{$article->title}</a>
122+
```
123+
124+
…a přidejte per-parametrový `FilterOut`, který titulek převede na URL-bezpečný tvar:
125+
126+
```php
127+
$router->addRoute('clanek/<id [0-9]+>[-<slug>]', [
128+
'presenter' => 'Article',
129+
'action' => 'detail',
130+
'slug' => [
131+
Route::FilterOut => fn($title) => Strings::webalize(Strings::truncate($title, 100, '')),
132+
],
133+
'' => [/* fallback s vyhledáním z předchozí ukázky */],
134+
]);
135+
```
136+
137+
Oba filtry spolupracují. Per-parametrový `FilterOut` proběhne první a předaný titulek převede na slug. Obecný filtr pak vidí, že slug je už vyplněn, a vyhledání v DB přeskočí. Šablony, které titulek nepředávají, dál fungují — projdou cestou s vyhledáváním.
138+
139+
Použijte to jen tam, kde to opravdu hraje roli (velké výpisy renderované stokrát za request). Pro většinu aplikace cachované vyhledávání stačí.
140+
141+
142+
Kanonizace: přesměrování na správnou URL
143+
========================================
144+
145+
Umíme teď generovat `/clanek/123-jak-upect-chleba`, ale routa pořád přijímá `/clanek/123` i `/clanek/123-cokoli-co-nekdo-napsal`. To je záměr — chceme krátké URL (viz níže) a chceme, aby staré nebo ručně napsané odkazy fungovaly. Ale nechceme, aby vyhledávače indexovaly stejný článek pod několika adresami.
146+
147+
Řešením je [kanonizace |application:presenters#kanonizace]: když uživatel přijde po nekanonické URL, aplikace ho přesměruje 301 na správnou. Stará se o to metoda `canonicalize()`:
148+
149+
```php
150+
public function actionDetail(int $id, ?string $slug = null): void
151+
{
152+
$article = $this->facade->getArticle($id);
153+
if (!$article) {
154+
$this->error();
155+
}
156+
157+
// vygeneruje kanonickou URL přes stejný FilterOut
158+
// a pokud se liší od současné URL, přesměruje HTTP 301
159+
$this->canonicalize('detail', ['id' => $id]);
160+
161+
$this->template->article = $article;
162+
}
163+
```
164+
165+
`canonicalize()` vygeneruje kanonickou URL stejným způsobem jako `link()` (takže projde stejným `FilterOut`) a porovná ji s aktuální URL. Pokud se liší, přesměruje HTTP 301. Návštěvník skončí na správné URL, vyhledávače vidí jen jednu kanonickou verzi.
166+
167+
168+
Jedno místo, které určuje, jak slug vypadá
169+
==========================================
170+
171+
Všimněte si, že `Strings::webalize(Strings::truncate(..., 100, ''))` žije na **jediném místě** — uvnitř `SlugProvider` (nebo v per-parametrovém `FilterOut`). Stejná logika vyrobí odkaz v šabloně, URL v `redirect()` i kanonický tvar v `canonicalize()`.
172+
173+
Když budete chtít pravidla později změnit (jiný limit délky, jiná transliterace, vyhazování dalších znaků), upravíte jeden řádek. Bez tohoto byste riskovali, že `redirect()` vygeneruje `/clanek/123-jak-upect-chleba`, zatímco `canonicalize()` bude očekávat `/clanek/123-jak-upect-chl` (protože někde někdo použil jiný `truncate`), a aplikace by se přesměrovávala donekonečna.
174+
175+
176+
Bonus: krátké URL stále fungují
177+
===============================
178+
179+
Protože je slug nepovinný, fungují i adresy bez něj:
180+
181+
```
182+
/clanek/123
183+
```
184+
185+
To se hodí pro:
186+
- **QR kódy** — kratší URL znamená méně hustý a lépe skenovatelný kód
187+
- **SMS a chat** — vejde se do tweetu, vypadá úhledně
188+
- **Tištěné materiály** — krátkou URL se rychleji napíše
189+
190+
Když uživatel takovou URL otevře, `canonicalize()` ho přesměruje 301 na plnou verzi se slugem, takže vyhledávače stejně uvidí jen kanonický tvar. Můžete mít krátkost i SEO zároveň.
191+
192+
193+
Shrnutí
194+
=======
195+
196+
- Maska `<id>[-<slug>]` dělá slug nepovinným. Defaultní `<slug>` nematchuje `/`; `<slug .+>` použijte jen tehdy, když opravdu chcete lomítka ve slugu.
197+
- Obecný `FilterOut` pod klíčem `''` dohledá titulek podle ID — **bez zásahu do šablon kdekoli v aplikaci**.
198+
- Vyhledávání obalte drobnou per-request cache; jeden DB dotaz na unikátní ID stačí.
199+
- Volitelně může per-parametrový `FilterOut` umožnit šablonám titulek předat přímo a vyhledávání přeskočit.
200+
- `$this->canonicalize()` v action přesměruje nekanonické URL na správnou s HTTP 301.
201+
- Vzorec pro slug (`webalize` + `truncate`) žije na jednom místě — změníte ho jednou, projeví se všude.
202+
- Krátké URL jen s ID dál fungují, což se hodí pro QR kódy a SMS.
203+
204+
Více o filtrech a kanonizaci najdete v dokumentaci [routování |application:routing#obecne-filtry] a [presenterů |application:presenters#kanonizace].

best-practices/en/@home.texy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Nette Application
1919
- [Dynamic Snippets |dynamic-snippets]
2020
- [How to Use the #Requires Attribute |attribute-requires]
2121
- [How to Properly Use POST Links |post-links]
22+
- [Pretty URLs with Slugs |pretty-urls]
2223

2324
</div>
2425
<div>

0 commit comments

Comments
 (0)