|
| 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]. |
0 commit comments