Přeskočit na obsah

i18n: vícejazyčnost, jazykové URL a hreflang

Stav dokumentu: Návrh — aplikace je dnes prakticky jednojazyčná; cílový stav je veřejný web s jazykovým prefixem v URL, výchozí angličtinou na /en a českou verzí na /cs.

Cíl

Připravit Soul of Herbs jako vícejazyčný web bez SEO duplicit: každý veřejný obsah má jednoznačnou jazykovou URL, správné canonical, hreflang alternativy a přepínač jazyka v hlavičce i patičce. Výchozí jazyk webu je angličtina na /en; čeština je dostupná na /cs.

Principy

  • URL jako zdroj pravdy: jazyk je vždy první segment veřejné HTML stránky: /en/..., /cs/....
  • Výchozí jazyk: /en je defaultní verze pro x-default; root / nesmí renderovat duplicitní obsah, má přesměrovat na /en nebo jinak jednoznačně kanonizovat.
  • Žádné SEO duplicity: každá lokalizovaná stránka má canonical sama na sebe a kompletní sadu hreflang odkazů na dostupné jazykové varianty.
  • Jazykový přepínač je URL-first: přepnutí jazyka vede na ekvivalentní stránku v druhém jazyce, pokud existuje; jinak na jazykový overview nebo bezpečný fallback.
  • Postupná migrace: nejdřív architektura rout a meta helperů, potom lokalizace UI textů, potom obsahová data.

Rozsah jazyků

První fáze:

KódJazykURL prefixRole
enEnglish/envýchozí veřejná verze, x-default
csČeština/csčeská lokalizace

Backlog:

  • Přidat další jazyky jen přes centrální konfiguraci podporovaných locales.
  • Nepřidávat jazyk do UI, dokud nemá rozhodnuté URL, metadata a fallback chování.

URL a routy

Cílový tvar

  • /en
  • /en/herbs
  • /en/herbs/:slug
  • /en/season/:month
  • /en/topics
  • /en/topics/:slug
  • /en/regions
  • /en/regions/:slug
  • /en/processing
  • /en/processing/:slug
  • /en/rituals
  • /en/rituals/:slug
  • /en/recipes
  • /en/docs
  • /en/docs/:slug

České ekvivalenty:

  • /cs
  • /cs/byliny
  • /cs/byliny/:slug
  • /cs/sezona/:month
  • /cs/symptomy
  • /cs/symptomy/:slug
  • /cs/region
  • /cs/region/:slug
  • /cs/zpracovani
  • /cs/zpracovani/:slug
  • /cs/ritualy
  • /cs/ritualy/:slug
  • /cs/recepty
  • /cs/dokumentace
  • /cs/dokumentace/:slug

Přechod ze současných URL

  • Bez zpětné kompatibility: staré české URL bez prefixu (/byliny, /symptomy, …) není potřeba dlouhodobě udržovat.
  • Po zapnutí i18n staré neprefixované veřejné HTML routy nesmí renderovat indexovatelný obsah.
  • Preferované chování:
    • / → redirect na /en
    • staré české routy bez prefixu → 404; žádná garance redirectů ani zpětné kompatibility
    • sitemap a interní odkazy obsahují pouze /en/... a /cs/...
  • Alias routy (/co-sbirat/:month, /byliny-na-zpracovani/:slug) nepřenášet jako veřejné i18n canonical routy, pokud k nim nebude explicitní produktový důvod.

Routing a technická architektura

  • Zavést centrální locale konfiguraci, např. app/lib/i18n-locales.ts:
    • podporované locale kódy
    • default locale en
    • validace locale segmentu
    • labely pro přepínač jazyka
  • Zavést helpery pro lokalizované URL:
    • sestavení cesty pro konkrétní route key a locale
    • mapování aktuální route na alternativní locale URL
    • fallback, pokud ekvivalentní stránka v cílovém jazyce neexistuje
  • Route soubory držet tenké:
    • validace params.locale
    • loader předává locale do DB/lib vrstvy
    • meta helper dostane locale a alternativy
  • Nepřidávat i18n logiku ad hoc do jednotlivých komponent.

Obsah a data

UI texty

  • UI copy přes malý slovník po doménách, např. app/lib/i18n/messages/*.ts.
  • Nepřekládat přes runtime string concatenation tam, kde se mění slovosled; používat celé věty nebo malé formattery.
  • Pluralizaci řešit přes explicitní helper per locale, ne české fráze použité pro angličtinu.

DB obsah

Rozhodnutý směr: samostatné překladové tabulky per veřejná entita (*_translations). Současné české sloupce zůstávají zdroj pro první migraci /cs, ale cílové veřejné čtení má používat překladové řádky podle locale.

  • Krátkodobě: existující česká pole zůstávají zdroj pro /cs, anglická verze může začít jen pro stránky, kde jsou překlady dostupné.
  • Cílově: lokalizovat názvy, popisy a textové obsahové bloky per entity.

Překladové tabulky

Minimální sada:

  • herb_translations
  • topic_translations
  • processing_method_translations
  • region_translations
  • spiritual_guide_translations
  • recipe_translations

Volitelně později:

  • plant_part_translations
  • habitat_translations
  • safety_warning_translations
  • spiritual_use_translations
  • scientific_evidence_translations jen pro redakční shrnutí/omezení, ne pro citovaná metadata studie

Společný tvar překladové tabulky

Každá překladová tabulka má držet stejný základ:

CREATE TABLE herb_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  herb_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  short_description TEXT,
  full_description TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (herb_id) REFERENCES herbs(id) ON DELETE CASCADE,
  UNIQUE (herb_id, locale),
  UNIQUE (locale, slug)
);

Konkrétní entity mohou mít navíc vlastní textová pole:

EntitaPřekladová pole navíc
Bylinyidentification_notes, confusion_risk_notes, drug_interactions_note, phototoxicity_note
Témataname, description, případně intro
Zpracováníname, short_description, full_description, required_equipment, safety_notes, typical_prep_note, safety_summary
Regionyname; typ regionu zůstává enum/slovník
Rituální návodytitle, intent, category, intro, steps_json, materials_json, safety_notes, references_note
Receptytitle, steps_json, notes, science_rationale_cs přejmenovat cílově na locale-neutral překladové pole

Konkrétní návrh tabulek

herb_translations

CREATE TABLE herb_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  herb_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  short_description TEXT,
  full_description TEXT,
  identification_notes TEXT,
  confusion_risk_notes TEXT,
  drug_interactions_note TEXT,
  phototoxicity_note TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (herb_id) REFERENCES herbs(id) ON DELETE CASCADE,
  UNIQUE (herb_id, locale),
  UNIQUE (locale, slug)
);

Stávající herbs.local_name zůstává jen zdroj pro první českou migraci; veřejné read modely mají cílově číst herb_translations.name.

topic_translations

CREATE TABLE topic_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  topic_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
  UNIQUE (topic_id, locale),
  UNIQUE (locale, slug)
);

processing_method_translations

CREATE TABLE processing_method_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  processing_method_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  short_description TEXT,
  full_description TEXT,
  difficulty_label TEXT,
  required_equipment TEXT,
  safety_notes TEXT,
  typical_prep_note TEXT,
  safety_summary TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (processing_method_id) REFERENCES processing_methods(id) ON DELETE CASCADE,
  UNIQUE (processing_method_id, locale),
  UNIQUE (locale, slug)
);

region_translations

CREATE TABLE region_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  region_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE,
  UNIQUE (region_id, locale),
  UNIQUE (locale, slug)
);

regions.type, country_code a hierarchie zůstávají sdílené; label typu regionu řeší locale slovník v kódu.

spiritual_guide_translations

CREATE TABLE spiritual_guide_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  spiritual_guide_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT NOT NULL,
  title TEXT NOT NULL,
  intent TEXT,
  category TEXT,
  intro TEXT NOT NULL,
  steps_json TEXT NOT NULL,
  materials_json TEXT,
  safety_notes TEXT,
  references_note TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (spiritual_guide_id) REFERENCES spiritual_guides(id) ON DELETE CASCADE,
  UNIQUE (spiritual_guide_id, locale),
  UNIQUE (locale, slug)
);

recipe_translations

CREATE TABLE recipe_translations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  herb_method_recipe_id INTEGER NOT NULL,
  locale TEXT NOT NULL,
  slug TEXT,
  title TEXT NOT NULL,
  steps_json TEXT NOT NULL,
  notes TEXT,
  science_rationale TEXT,
  seo_title TEXT,
  seo_description TEXT,
  is_published INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (herb_method_recipe_id) REFERENCES herb_method_recipes(id) ON DELETE CASCADE,
  UNIQUE (herb_method_recipe_id, locale),
  UNIQUE (locale, slug)
);

slug u receptu je volitelný, dokud recept nemá vlastní detailní routu; pokud zůstane /recepty jen index, unique index pro slug musí povolit NULL.

Co se nepřekládá v DB tabulkách

Tyto věci zůstávají sdílené napříč locale:

  • stabilní interní id
  • vazby mezi entitami (herb_id, topic_id, region_id, processing_method_id)
  • latinský název byliny
  • číselná data: měsíce, roky, časy přípravy, pořadí
  • R2 klíče médií a technická metadata obrázků
  • DOI, URL zdrojů, autoři, časopis a další bibliografická metadata
  • enum slugy (safety_level, study_type, evidence_level, claim_nature, facet) — veřejné labely se překládají slovníkem v kódu
  • publish stav původní entity jako hrubý master switch; detailní viditelnost řídí ještě is_published per překlad

Publikování a workflow

  • Entity má master is_published; locale verze má vlastní is_published.
  • Veřejná stránka v locale existuje jen když jsou publikované obě vrstvy: master entita i překlad.
  • Překlad se nevytváří automaticky při změně master dat.
  • Seed/migrace pro první fázi vytvoří české překlady z existujících českých sloupců.
  • Anglické překlady se přidávají postupně a publikují per entita.

Migrace existujících textů

  • První D1 migrace vytvoří překladové tabulky a naplní locale = 'cs' z dnešních českých sloupců.
  • Po přepnutí veřejných read modelů na *_translations se původní textové sloupce v master tabulkách přestanou používat pro veřejný render.
  • Master tabulky si cílově mají nechat jen sdílená technická pole, vazby, enumy a publish master switch.
  • Staré textové sloupce se nemažou ve stejném kroku jako přechod read modelů; odstranění je samostatný cleanup po ověření produkce.
  • Nový obsah se po migraci zapisuje už jen do překladových tabulek.

Čtení dat v aplikaci

DB read modely mají mít locale-aware variantu:

getPublishedHerbDetailBySlug(db, locale, slug)
listPublishedHerbs(db, { locale, ...filters })
listPublishedHerbSlugs(db, locale)

Pravidla:

  • Detail hledá řádek přes translations.locale + translations.slug.
  • Po nalezení detailu se alternativní URL generují přes stejné entity ID a dostupné publikované překlady.
  • Katalogové listy vrací jen entity s publikovaným překladem pro dané locale.
  • Filtry nad sdílenými vztahy (region, topic, processing) musí přijímat lokalizovaný slug a převést ho na stabilní ID.
  • Výstup read modelu nemá míchat česká pole z master tabulky do anglické stránky bez explicitního fallback rozhodnutí.

Fallback politika

SEO-safe výchozí pravidlo:

  • Detailní stránka bez publikovaného překladu v daném locale vrací 404.
  • Taková varianta se neobjeví v hreflang.
  • Taková varianta se neobjeví v sitemapě.
  • Indexové stránky mohou zobrazit méně položek podle dostupných překladů.
  • Počty na indexových stránkách (shown, total) se počítají jen z entit s publikovaným překladem pro aktuální locale.
  • Filter options se zobrazují jen pro číselníky/entity, které mají publikovaný překlad pro aktuální locale.
  • Empty state v daném locale je platný výsledek, pokud zatím nejsou publikované překlady.

Dočasný fallback českého obsahu do /en je povolen jen pro interní kontrolu nebo noindex stránky, ne pro indexovatelný veřejný detail.

Dokumentace a Markdown obsah

Docs stránky potřebují samostatné rozhodnutí:

  • Varianta A: lokalizované Markdown soubory podle locale (docs/en/..., docs/cs/...).
  • Varianta B: registry mapující doc_id na locale soubory a slugy.

Doporučení: pro veřejnou dokumentaci použít registry s doc_id, locale, slug, title, path, is_published, aby šlo generovat hreflang stejně jako u DB entit.

Vyhledávání a indexace

  • Textové vyhledávání musí být locale-aware.
  • Fold/search helpery nesmí předpokládat češtinu pro angličtinu.
  • Budoucí FTS/embeddings indexovat odděleně per locale.
  • Query parametry filtrů používají lokalizované slugy v URL, ale DB dotazy pracují se stabilními ID.
  • q hledá jen v překladových polích aktuálního locale a v locale-neutral polích, kde to dává smysl (např. latinský název).
  • Parametry jako region, topic, pm, part používají lokalizovaný slug pro aktuální locale.
  • Loader převede lokalizovaný slug na stabilní interní ID a až potom skládá SQL filtr.
  • Kombinace filtrů nesmí míchat slugy z různých locale; neplatný slug pro locale se chová jako neexistující filtr nebo 404 podle typu routy.

Slugy a fallbacky

  • Slug může být odlišný per jazyk (/cs/byliny/kopriva-dvojdoma, /en/herbs/stinging-nettle).
  • Každá detailní stránka musí umět najít alternativní URL podle stabilního entity ID, ne podle stejného slug stringu.
  • Pokud překlad entity chybí, platí výchozí fallback politika výše: žádný indexovatelný detail, žádný hreflang, žádná sitemap URL.
  • hreflang se generuje jen pro reálně dostupné publikované varianty.

SEO metadata

Každá indexovatelná lokalizovaná HTML stránka musí mít:

  • html lang podle locale (en, cs)
  • title a description v jazyce stránky
  • canonical na vlastní lokalizovanou URL
  • hreflang="en" na anglickou variantu, pokud existuje
  • hreflang="cs" na českou variantu, pokud existuje
  • hreflang="x-default" na defaultní anglickou variantu
  • OG/Twitter metadata v jazyce stránky
  • og:locale odpovídající jazyku (en_US, cs_CZ)

Hreflang musí být reciproční: pokud /en/herbs/stinging-nettle odkazuje na /cs/byliny/kopriva-dvojdoma, česká stránka musí odkazovat zpět na anglickou.

Sitemap

  • Sitemap musí obsahovat lokalizované kanonické URL, ne staré neprefixované URL.
  • Pro každou URL s alternativami doplnit xhtml:link rel="alternate" hreflang="...".
  • Nezahrnovat jazykové varianty, které vrací 404, redirect nebo fallback bez indexace.
  • Sladit se zadáním sitemap-xml-vylepseni.md.

Příklad cílového XML tvaru:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://soulofherbs.com/en/herbs/stinging-nettle</loc>
    <xhtml:link rel="alternate" hreflang="en" href="https://soulofherbs.com/en/herbs/stinging-nettle" />
    <xhtml:link rel="alternate" hreflang="cs" href="https://soulofherbs.com/cs/byliny/kopriva-dvojdoma" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://soulofherbs.com/en/herbs/stinging-nettle" />
  </url>
  <url>
    <loc>https://soulofherbs.com/cs/byliny/kopriva-dvojdoma</loc>
    <xhtml:link rel="alternate" hreflang="en" href="https://soulofherbs.com/en/herbs/stinging-nettle" />
    <xhtml:link rel="alternate" hreflang="cs" href="https://soulofherbs.com/cs/byliny/kopriva-dvojdoma" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://soulofherbs.com/en/herbs/stinging-nettle" />
  </url>
</urlset>

Přepínač jazyka

  • Viditelný v hlavní hlavičce i patičce.
  • Na desktopu jednoduchý přepínač EN / CS nebo menu, na mobilu stejně dostupný v navigaci.
  • Odkaz vede na ekvivalentní lokalizovanou URL aktuální stránky.
  • Aktivní jazyk je označený srozumitelně pro screen reader (aria-current nebo text).
  • Přepínač nesmí měnit jazyk přes klientský stav bez změny URL.

Chování při přepnutí jazyka

  • Přepnutí jazyka z libovolné stránky vždy zachová kontext: uživatel se dostane na překlad té samé stránky v cílovém jazyce, ne na jazykovou homepage.
  • Mapování ekvivalentní URL vychází ze stabilního interního ID entity (route key + entity ID), ne ze stejného slug stringu napříč jazyky.
  • Pravidla mapování podle typu stránky:
    • Statická routa (/en/cs, /en/herbs/cs/byliny, /en/season/cs/sezona, atd.): přepnutí vede na ekvivalentní statickou routu v cílovém jazyce přes route key.
    • Detail entity (/en/herbs/:slug/cs/byliny/:slug): přepnutí najde záznam *_translations pro cílový locale přes stejné entity_id a sestaví URL z jeho lokalizovaného slugu.
    • Indexy s filtry (?region=..., ?topic=..., ?pm=..., ?part=..., ?q=...): lokalizované slugy ve query se přemapují přes stabilní ID; locale-neutral filtry (?month=...) zůstávají beze změny. Pokud některý filtr nelze přeložit (chybí překlad číselníku v cílovém locale), filtr se zahodí, ne celé URL.
  • Fallback, když ekvivalent neexistuje:
    • Detail bez publikovaného překladu pro cílový jazyk → přepínač vede na index dané sekce v cílovém jazyce (např. /en/herbs), ne na homepage.
    • Pokud i ten chybí, fallback na jazykovou homepage (/en, /cs).
    • Položka v menu pro nedostupný překlad může být vizuálně utlumená s aria poznámkou („překlad není k dispozici“), ale stále vede na výše popsaný fallback, ne na homepage.
  • Přepínač nesmí ztratit hloubku navigace: query parametry s lokalizovanými slugy se přemapují (viz výše), kotvy (#…) se zachovávají beze změny.
  • Volba jazyka může být persistována (cookie / localStorage) jen jako preference pro budoucí návštěvy; nikdy nesmí přepsat URL při kliknutí ani způsobit redirect z explicitně vyžádané locale URL.
  • Pro výpočet cílové URL existuje helper, např. getLocalizedHref(routeKey, locale, params), který používají loadery i hreflang linky, aby přepínač a SEO metadata byly konzistentní.

Fázovaný rollout

Fáze 0: Dokumentační rozhodnutí

  • Zapsat, že staré neprefixované URL nemají garantovanou zpětnou kompatibilitu.
  • Potvrdit en_US a cs_CZ pro OG locale.
  • Potvrdit *_translations model jako jediný veřejný obsahový zdroj po migraci.

Fáze 1: i18n kostra bez DB překladů

  • Přidat locale konfiguraci a helper pro validaci locale segmentu (en, cs). → app/lib/i18n/locales.ts
  • Přidat route/mapping helper pro lokalizované URL. → app/lib/i18n/route-keys.ts + app/lib/i18n/localized-href.ts
  • Přidat meta helper pro hreflang linky, x-default a lokalizované og:locale. → app/lib/i18n/hreflang-meta.ts + app/lib/i18n/website-meta.ts
  • Přidat testy pro canonical + hreflang sadu na reprezentativních routách.
  • Root / přesměrovat na /en. (routes/home.tsxredirect("/en", 308))

Fáze 2: První veřejné routy a language switcher

  • Zavést /en a /cs jako jazykové homepage. → routes/locale-home.tsx + route(":locale", …)
  • Přidat language switcher do hlavičky. → LanguageSwitcher (hlavička)
  • Přidat language switcher do patičky. → LanguageSwitcher placement="top" align="center" (patička)
  • Upravit interní odkazy, aby používaly aktuální locale. (rámcový UI: hlavička/patička přes getMessages(locale); content routy zůstávají neprefixované do Fáze 3+)
  • Upravit 404 texty a redirecty pro locale-aware chování. (ErrorBoundary v root.tsx čte locale z URL, zpětný odkaz vede na /<locale>)

Poznámky k výsledku Fáze 2 a aktuální stav:

  • /ennení zrcadlo CS: po Fázi 3–5 a EN seedu z migrace 0084 má vlastní katalogové sekce a sezónu. routes/locale-home.tsx proto noindex neposílá — homepage /en je indexovatelná a v sitemap.
  • Prefixované content routy (/cs/byliny, /en/herbs, /cs/sezona, /en/season, …) jsou hotové; legacy neprefixované české URL vrací 308 na /cs/... (viz § Fáze 3 / 5).
  • LanguageSwitcher na detailech používá mapy *TranslationSlugs z React Contextu (HerbCatalog…, TopicCatalog…, atd.). Pokud cílový překlad neexistuje, padáme na index dané sekce (isFallback: true) — UI to ukáže přes title.
  • PLANNED_LOCALES (pl, de, fr, es, it, pt) jsou v menu jako „(brzy)“ s aria-disabled — bez <Link> a mimo hreflang sadu.

Fáze 3: První obsahový řez přes byliny

  • Přidat herb_translations. → migrations/0082_herb_translations.sql
  • Naplnit české překlady z dnešních polí herbs. (INSERT … SELECT FROM herbs v migraci 0082)
  • Přidat malou sadu anglických překladů pro reprezentativní byliny. (kopriva-dvojdomastinging-nettle, pampeliska-lecivadandelion; další byliny EN řádek zatím nemají → nezobrazí se v /en/herbs.)
  • Přepnout /en/herbs, /cs/byliny, /en/herbs/:slug, /cs/byliny/:slug na locale-aware read modely. → app/routes/locale-herb-catalog._index.tsx + locale-herb-catalog.$slug.tsx, listPublishedHerbs(..., { locale }), getPublishedHerbDetailBundleForLocale(db, locale, slug), listPublishedHerbSlugs(db, locale).
  • Ověřit detail s odlišným slugem per locale. (Karta byliny si přes HerbCatalogSwitchContext předává slugy do LanguageSwitcher; computeSwitcherTarget mapuje /cs/byliny/kopriva-dvojdoma/en/herbs/stinging-nettle. Testy: app/lib/i18n/switch-target.test.ts.)

Poznámky k výsledku Fáze 3:

  • Legacy /byliny a /byliny/:slug vrací 308 redirect na /cs/byliny[/:slug] (zachovává query a hash) — místo doc-policy „staré české routy → 404“ jsme zvolili 308, aby externí odkazy a interní legacy linky nepadaly. Sitemap už staré neprefixované URL neobsahuje (viz Fáze 4).
  • HerbDetailExtended má nově id (kvůli listPublishedSpiritualGuideSummariesForHerbId a getPublishedHerbTranslationSlugsByHerbId).
  • localizedWebsiteMeta má nové parametry hreflangAlternates, ogType a ogImageHref — využívá je herbDetailRouteMeta pro reciproční hreflang u karet s odlišnými slugy a pro og:image z první karty.

Fáze 4: SEO a sitemap pro první řez

  • Doplnit hreflang na indexové stránky. (localizedWebsiteMeta v routes/locale-herb-catalog._index.tsx skládá hreflang sadu z ROUTE_PATTERNS["herbs.index"] přes buildHreflangMeta. Stejnou cestou jsou doplněné i indexy témat, zpracování, regionů a rituálů ve Fázi 5.)
  • Doplnit hreflang na detailní stránky podle dostupných alternativ. (localizedHerbDetailRouteMeta + analogické localizedTopicDetailRouteMeta, localizedProcessingDetailRouteMeta, localizedRegionDetailRouteMeta, localizedRitualDetailRouteMeta. Každá generuje hreflang jen pro publikované překlady z *_translations.)
  • Upravit /sitemap.xml na lokalizované URL a alternate linky. (app/routes/sitemap[.]xml.tsx vrací jen prefixované /cs/... a /en/..., EN segmenty se přidají jen když existují EN slugy. Staré neprefixované katalogové URL už v mapě nejsou; aliasy /byliny-na-zpracovani/:slug zůstávají jen u processing kvůli historickým odkazům.)
  • Přidat smoke test pro vybrané dvojice /en/... + /cs/.... (app/lib/i18n/switch-target.test.ts kryje herb / topic / processing / region / ritual párů přes computeSwitcherTarget; app/lib/i18n/herb-catalog-path.test.ts, topic-catalog-path.test.ts, processing-catalog-path.test.ts, region-catalog-path.test.ts, rituals-catalog-path.test.ts ověřují parser pro každou entitu.)
  • Ověřit reciproční hreflang na vzorku detailů. (hreflangAlternates v localized*DetailRouteMeta se počítá z getPublished*TranslationSlugsBy*Id — chybějící překlad se prostě neobjeví; CS i EN strana používají stejnou mapu, takže reciprocita drží z jednoho zdroje.)

Fáze 5: Rozšíření na další entity

  • Přidat topic_translations, processing_method_translations, region_translations, spiritual_guide_translations. (Migrace migrations/0083_*_translations.sql — sdílený tvar překladové tabulky, UNIQUE (entity_id, locale) a UNIQUE (locale, slug). Migrace 0082 přidala herb_translations ve Fázi 3.)
  • Recepty: /cs/recepty/en/recipes (parser app/lib/i18n/recipes-catalog-path.ts, routa routes/locale-recipes._index.tsx); legacy /recepty → 308 na /cs/recepty. listPublishedRecipesCatalog(db, locale) joinuje herb_translations a processing_method_translations (INNER) + LEFT JOIN recipe_translations rt ON locale = ? AND is_published = 1 s COALESCE(rt.title, hmr.title) / COALESCE(rt.science_rationale, hmr.science_rationale_cs). Stejné překlady tahá karta byliny (herb-detail.server.ts) pro title/notes/steps_json/science_rationale a processing-methods.server.ts pro odkazy z detailu zpracování. Migrace 0090_recipe_translations.sql založila tabulku (UNIQUE (herb_method_recipe_id, locale), sloupce title, notes, steps_json, science_rationale, is_published) a naseedovala CS řádky z masteru; EN řádky se přidávají ručně, do té doby fallbackuje COALESCE na master.
  • Přepnout přehledy a detaily témat, regionů, zpracování a rituálů na locale-aware read modely.
    • Témata: app/db/topics.server.ts čte z topic_translations; routy app/routes/locale-topics._index.tsx a app/routes/locale-topics.$slug.tsx slouží /cs/symptomy[/:slug] a /en/topics[/:slug]. Legacy routes/symptomy.* → 308 na /cs/symptomy[/:slug].
    • Zpracování: app/db/processing-methods.server.ts čte z processing_method_translations; routy routes/locale-processing._index.tsx a routes/locale-processing.$slug.tsx. Legacy routes/zpracovani.* → 308 na /cs/zpracovani[/:slug]; alias /byliny-na-zpracovani/:slug přesměrovává na /cs/zpracovani/:slug.
    • Regiony: app/db/regions.server.ts čte region_translations.name/slug a join region_translations pro lokalizovaný continentName/subregionName (fallback na master name). Routy routes/locale-regions._index.tsx a routes/locale-regions.$slug.tsx; legacy routes/region.* → 308 na /cs/region[/:slug].
    • Rituály: app/db/spiritual-guides.server.ts čte spiritual_guide_translations; SpiritualGuideDetail má nově id. Routy routes/locale-rituals._index.tsx a routes/locale-rituals.$slug.tsx; legacy routes/ritualy.* → 308 na /cs/ritualy[/:slug].
    • Parsery URL: app/lib/i18n/topic-catalog-path.ts, processing-catalog-path.ts, region-catalog-path.ts, rituals-catalog-path.ts — všechny pokryté unit testy. computeSwitcherTarget rozezná každý typ stránky a používá příslušné *TranslationSlugs z React Contextu (TopicCatalogSwitchProvider, ProcessingCatalogSwitchProvider, RegionCatalogSwitchProvider, SpiritualGuideSwitchProvider) napojené v root.tsx.
    • Hlavní navigace (app/root.tsx) odkazuje na lokalizované cíle přes getLocalizedHref(routeKey, locale) (bylo by zbytečné chodit přes legacy 308).
  • Implementovat fallback pravidla pro chybějící překlad podle tohoto dokumentu.
    • Detail bez publikovaného překladu → 404 přes notFoundResponse(...) v locale rutě (/en/topics/jen-cs-slug se nezobrazí).
    • Indexové stránky listují jen entity s is_published = 1 v *_translations.locale.
    • hreflang se generuje jen pro reálně publikované překlady (mapa getPublished*TranslationSlugsBy*Id).
    • LanguageSwitcher pro detail bez ekvivalentního slugu fallbackuje na index dané sekce v cílovém jazyce ({ href: getLocalizedHref("rituals.index", target), isFallback: true }) — UI to indikuje přes title atribut s nápovědou „překlad zatím není k dispozici“.
    • Sitemap vynechává jazykové varianty bez slugu (např. EN segment /en/rituals se přidá jen když listPublishedSpiritualGuideSlugs(db, "en") vrátí aspoň jeden záznam).

Poznámky k výsledku Fáze 5:

  • Master tabulky (topics, processing_methods, regions, spiritual_guides) si dál drží stabilní id, vazby, enumy a master is_published. Veřejné slugy a názvy řídí výhradně *_translations.
  • RegionDetail / RegionListItemcontinentName a subregionName lokalizované (LEFT JOIN region_translations jako crt/srt s fallbackem na continents.name / subregions.name), ale slugy kontinentu a subregionu zůstávají locale-neutral (anchor #continent-… musí být stejný pro CS i EN).
  • LanguageSwitcher přijímá pět volitelných map slugů (herbTranslationSlugs, topicTranslationSlugs, processingMethodTranslationSlugs, regionTranslationSlugs, spiritualGuideTranslationSlugs). Kontext si na detailu nastaví loader přes useLayoutEffect a smaže ho na unmount, takže navigace mezi typy detailů nemíchá staré slugy do nového targetu.
  • Sezóna měsíční (/cs/sezona[/:month]/en/season[/:month]):
    • season.month parametr je locale-neutral (1–12); URL prefix se ale liší (sezona vs season), takže parseSeasonCatalogPathname v computeSwitcherTarget ho rozezná a přepne na cílový segment přes getLocalizedHref("season.month", target, { params: { month } }). Query (?region=…) a hash se zachovávají.
    • DB read modely v app/db/sezona.server.ts (loadSezonaMonthHerbContextBySlug, listDistinctPlantPartsForMonth, countPublishedHerbsWithHarvestInMonth) přijímají locale a join na herb_translations zajišťuje, že klíče v ctxBySlug jsou ve stejném locale jako HerbCatalogItem.slug z listPublishedHerbs(..., { locale }). harvest_periods je locale-neutral fakt, ale herb_in_region pracuje s region_translations per locale.
    • plant_parts.slug zůstává locale-neutral (URL filter ?part=list); od migrace 0085 se ale name překládá přes plant_part_translations (LEFT JOIN s COALESCE(ppt.name, pp.name) v sezóně, katalogovém filter optionsu i detailu byliny). EN seed obsahuje Leaf / Root / Flower pro list / koren / kvet.
    • UI texty (breadcrumbs, intro, tabulka, prázdný stav, filtr regionů, krátké názvy měsíců) jsou lokalizované přes UiMessages.season v app/lib/i18n/messages/{cs,en}.ts. MONTH_LABELS_EN a MONTH_LABELS_SHORT_EN přibyly v app/lib/month-labels-en.ts.
    • localizedSezonaMonthRouteMeta v app/lib/sezona-month-meta.ts skládá title + description + hreflang přes localizedWebsiteMeta (route key season.month); regionální „vybraný region" / „selected region" varianta se přepíná podle locale.
    • Legacy routes/sezona.* → 308 na /cs/sezona[/:month] (zachovává query/hash). Sitemap nově obsahuje /cs/sezona, /en/season a 12 měsíců v každém locale; staré neprefixované sezonaMonthPaths se nahradily.
    • Alias /co-sbirat/:month zůstává jen jako redirect (routes/co-sbirat.$month.tsx) — locale verze pro něj nevzniká, dokud nebude explicitní produktový důvod.

Fáze 5.5: Seed prvního EN řezu + recepty

  • Dokumentace (/cs/dokumentace[/:slug]/en/docs[/:slug]) — parser app/lib/i18n/docs-catalog-path.ts, routy routes/locale-docs._index.tsx a routes/locale-docs.$slug.tsx, legacy routes/dokumentace.* → 308. rewriteDocsHref(href, locale) přepisuje relativní .md odkazy na lokalizovaný cíl, DocsMarkdown přijímá locale. Slug .md souboru je locale-neutral (sdílí ho docs/ v repu); localizedDokumentaceDocRouteMeta skládá canonical + hreflang přes localizedWebsiteMeta. UiMessages.docs pokrývá breadcrumb, intro a count fráze; sitemap nově emituje CS i EN docs URL; switch-target zná oba prefixy. (Veřejná routa /roadmap byla zrušena — 11-roadmap.md a 24-engineering-roadmap.md zůstávají dostupné jen přes /cs/dokumentace/:slug/en/docs/:slug.)
  • Recepty (/cs/recepty/en/recipes) — parser, locale routa, legacy 308, locale-aware listPublishedRecipesCatalog, UiMessages.recipes, lokalizované odkazy z karet receptu na byliny a způsoby. Switch-target a sitemap aktualizovány.
  • Migrace 0088_en_translations_fifty_percent.sql (generuje scripts/gen_migration_0088.py): doplní publikovaným bylinám a tématům bez EN řádku seed pro (id % 2) = 1 (lichá ID; překryv s 0084 se přeskočí). Zpracování a regiony se neopakují.
  • Migrace 0084_en_translations_twenty_percent.sql (generuje scripts/gen_migration_0084.py):
    • herb_translations: pro publikované byliny bez EN řádku přidá EN pro (herb_id % 5) = 1 (~20 %), slug herb-<id>, jméno z latin_name (fallback CS), dlouhé texty zatím přebírá z CS varianty (označeno v short_description).
    • topic_translations: EN pro (topic_id % 5) = 1, slug <cs-slug>-en, anglické názvy z mapy ve skriptu, popis podle topics.type.
    • processing_method_translations: EN pro celý katalog (<cs-slug>-en, anglické názvy; difficulty_label mapovaný na Easy/Medium/Hard).
    • region_translations: EN pro kontinenty + cr (Czechia), stejný slug jako CS — kontinenty jsou kotvy #continent-….
  • Migrace 0085_plant_part_translations.sqlplant_part_translations se sdíleným slugem a překladem name; LEFT JOIN v sezona.server.ts, herb-detail.server.ts a getCatalogFilterOptions.
  • /en přestalo být zrcadlo — noindex vyhozen z locale-home.tsx, sitemap /en znovu obsahuje.
  • countPublishedHerbsWithHarvestInMonth přijímá locale (počítá jen byliny, které mají publikovaný herb_translations řádek).

Fáze 6: Cleanup starých textových sloupců

  • Audit veřejného renderu — katalog, detaily témat/zpracování/regionů/rituálů, sezóna a karta byliny používají *_translations tam, kde tabulka existuje. Opraveno v tomto PR: na kartě byliny (getPublishedHerbDetailBundleForLocale) se pro výskyt, sběr (region u řádku) a blok zpracování bere region_translations / processing_method_translations (s COALESCE na master při chybějícím řádku); vazby téma ↔ bylina na kartě jdou přes topic_translations v daném locale (téma bez publikovaného překladu v jazyce se v seznamu neukáže).
  • Migrace 0086_storage_method_translations.sqlstorage_method_translations se sdíleným slugem master tabulky a překladem name + short_description; LEFT JOIN s COALESCE(smt.name, sm.name) ve skladovacím dotazu v herb-detail.server.ts. EN seed pro 5 stávajících slugů (suseni, chlad, med, alkohol, hermeticky).
  • Migrace 0092_harvest_period_translations.sql — překlad harvest_notes u řádků sběru:
    • Tabulka harvest_period_translations (UNIQUE (harvest_period_id, locale), sloupce harvest_notes, is_published, časové stopy) + index (locale, is_published).
    • CS seed = kopie master harvest_periods.harvest_notes; is_published = 1 (master harvest_periods nemá is_published, viditelnost se řídí přes herbs.is_published).
    • Read model v herb-detail.server.ts (sekce „Sběr a sklizeň") přepnut na LEFT JOIN harvest_period_translations hpt … COALESCE(hpt.harvest_notes, hp.harvest_notes). sezona.server.ts ani jiné dotazy harvest_notes na veřejné stránce nečtou.
    • Master sloupce growth_phase, best_time_of_day, weather_notes zůstávají locale-neutral a zatím nejsou v public renderu; pokud se začnou zobrazovat, doplní se buď enumy s UI labely, nebo do harvest_period_translations v navazující migraci.
  • Migrace 0091_content_block_translations.sql — tři lokalizační tabulky pro redakční bloky karty byliny:
    • spiritual_use_translations (title, description, source_note). Enumy (category, use_form, claim_strength, claim_nature, facets) a cultural_context zůstávají v masteru spiritual_uses.
    • scientific_evidence_translations (claim_summary, limitations, preparation_form, dosage_notes). V masteru scientific_evidence zůstávají active_compound, enumy (evidence_level, study_type) a citační metadata (source_title, source_url, doi, authors, journal, publication_year).
    • safety_warning_translations (title, description). Enumy (warning_type, severity) a source_url zůstávají v masteru safety_warnings.
    • CS seed = kopie master polí; is_published se zrcadlí (u safety_warnings, který nemá is_published, je CS řádek vždy publikovaný).
    • herb-detail.server.ts přepnut na LEFT JOIN *_translations AS … COALESCE(…) pro všechny tři bloky i pro recept-evidence join (/byliny/.../#recepty přebírá lokalizovanou claim_summary / limitations / preparation_form / dosage_notes).
  • Aktualizovat 22-data-model.md — nová sekce „i18n a veřejné čtení (Fáze 6)“ + pravidlo, že DROP master textů je samostatná migrace.
  • Samostatná migrace: odstranit nebo archivovat nepoužívané textové sloupce v herbs, topics, spiritual_uses, scientific_evidence, safety_warnings, herb_method_recipes, harvest_periods až po dalším auditu a přepnutí admin nástrojů.

Zbývající dluh (veřejný text z masteru / bez locale): žádný okruh veřejně renderovaného obsahu karty byliny už nečte volnotextový sloupec přímo z masteru. Karta byliny i recepty mají dedikované překladové tabulky pro všechny renderované bloky — recipe_translations (0090), spiritual_use_translations / scientific_evidence_translations / safety_warning_translations (0091), harvest_period_translations (0092). CS seed je všude nasazen, EN řádky se přidávají postupně — do té doby vrací COALESCE master. Viz sekce v 22-data-model.md.

Přehled zbývající práce (orientační)

OblastStavPoznámka
Routy + switcher + sitemap + UiMessagesHotovoJádro i18n v provozu.
EN seed bylin/témat (0084 + 0088)Hotovo~20 % (id % 5 = 1) + lichá id bez EN; sudá id mimo 0084 stále často bez EN — další vlna seedu nebo ruční překlady.
recipe_translations + read model receptůHotovo (struktura)Tabulka + CS seed v 0090, read modely COALESCE. Migrace 0093_recipe_translations_en_catalog_seed.sql: pro recepty s publikovaným herb_translations + processing_method_translations v en přidá EN řádek s anglickým titulkem (jméno byliny + metoda + část rostliny z EN překladů); kroky a science_rationale zatím z CS řádku / masteru — ruční EN text postupně.
Spirituální / vědecké / varování na kartěHotovo (struktura)Tabulky spiritual_use_translations, scientific_evidence_translations, safety_warning_translations v 0091 + read modely COALESCE (včetně recept-evidence joinu). EN obsah se dohrává ručně (zatím fallback na CS).
harvest_periods.harvest_notesHotovo (struktura)Tabulka harvest_period_translations v 0092 + read model herb-detail.server.ts COALESCE. EN poznámky se dohrávají ručně.
/cs/roadmap/en/roadmapVyřazenoVeřejná routa /roadmap smazána; produktová a engineering roadmapa žijí jen jako docs/11-roadmap.md a docs/24-engineering-roadmap.md (přístupné přes /cs/dokumentace/:slug/en/docs/:slug).
DROP master textových sloupcůZbýváSamostatná migrace po dokončení přepnutí a admin nástrojů.
Plánované jazyky (pl, de, …)BacklogJen UI „(brzy)“ bez rout.

Testování

  • Unit:
    • locale parser
    • URL buildery
    • route key → localized path mapping
    • hreflang builder
    • pluralizace / label helpery
  • Meta tests:
    • /en canonical + x-default
    • /cs canonical + alternates
    • detail entity s odlišnými slugy per locale
    • chybějící lokalizace se neobjeví v alternates
  • Smoke:
    • /en, /cs
    • jedna detailní bylina v obou jazycích
    • sitemap obsahuje lokalizované URL a alternate linky
    • staré neprefixované URL se nechová jako indexovatelná duplicita

Akceptace

  • Root / má jednoznačné chování bez duplicitní indexace, preferovaně redirect na /en. (routes/home.tsx → 308 na /en.)
  • /en a /cs fungují jako jazykové vstupy. (routes/locale-home.tsx + parser parseSupportedLocale.)
  • Hlavička i patička obsahují language switcher. (LanguageSwitcher v app/root.tsx.)
  • Hlavní indexové routy mají správné canonical, hreflang a x-default. (localizedWebsiteMeta + buildHreflangMeta na locale-herb-catalog._index.tsx, locale-topics._index.tsx, locale-processing._index.tsx, locale-regions._index.tsx, locale-rituals._index.tsx, locale-season._index.tsx.)
  • Minimálně jeden detailní typ stránky podporuje odlišný slug per locale a generuje reciproční hreflang. (Karta byliny: /cs/byliny/kopriva-dvojdoma/en/herbs/stinging-nettle přes getPublishedHerbTranslationSlugsByHerbId.)
  • Veřejné entity používají překladové tabulky *_translations s unikátními indexy (entity_id, locale) a (locale, slug). (Migrace 0082–0085; plant_part_translations má jen UNIQUE (plant_part_id, locale) — slug zůstává master.)
  • Sitemap neobsahuje staré neprefixované duplicity a obsahuje lokalizované URL. (routes/sitemap[.]xml.tsx skládá jen /cs/... a /en/....)
  • Testy pokrývají locale parsing, URL buildery, hreflang a sitemap alternates. (app/lib/i18n/*.test.ts, app/lib/i18n/switch-target.test.ts, *-catalog-path.test.ts.)

Rizika a rozhodnutí

  • Změna URL je SEO citlivá: před nasazením redirectů připravit matici stará URL → nová URL.
  • Překlad slugů vyžaduje stabilní entity ID: nelze spoléhat na stejný slug napříč jazyky.
  • Částečné překlady mohou vytvářet tenké stránky: chybějící obsah raději nevkládat do hreflang/sitemap.
  • Angličtina jako default mění produktovou prioritu: současný český obsah bude muset mít plán překladu, jinak /en nesmí působit jako nedokončený web.

Související dokumenty