Osy#betaRun it on Osyrin: osyrin.com

Reference / I18n

Every language's page, for a search engine — hreflang, canonical, sitemap

<link rel="alternate" hreflang="sv" href="…/sv/p/x"> · x-default · a /sv/ page's canonical is /sv/… · sitemap.xml: each page once per language, with xhtml:link alternates

An app that speaks more than one language tells search engines where each page lives in each one, with nothing written in its source: every public page's head names its address in every language and `x-default`, its canonical is the address in the language it is served in, and a served sitemap lists each page once per language with its alternates. What a page's head says about itself — its title, description and structured data — is in the served language, because the head runs in it.

stable1 example compiled by CIi18nlanguageseositemap

Summary#

Each language of a page has one address: the source language's is the page's own path (/p/kiambu), every other language's is the same path under its code (/sv/p/kiambu), served in place (Which language a reader gets — and switching it). Search engines are told so in three places, by the platform, for every app that speaks more than one language.

Signature#

<!-- the head of every public page, served as /sv/p/kiambu -->
<link rel="alternate" hreflang="en" href="https://shop.example.com/p/kiambu">
<link rel="alternate" hreflang="sv" href="https://shop.example.com/sv/p/kiambu">
<link rel="alternate" hreflang="x-default" href="https://shop.example.com/p/kiambu">
<link rel="canonical" href="https://shop.example.com/sv/p/kiambu">

<!-- a served sitemap: each <url> once per language, each carrying the whole set -->
<url>
  <loc>https://shop.example.com/sv/p/kiambu</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://shop.example.com/p/kiambu"/>
  <xhtml:link rel="alternate" hreflang="sv" href="https://shop.example.com/sv/p/kiambu"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://shop.example.com/p/kiambu"/>
</url>

Description#

The head of every public page — one a signed-out visitor may open, which is all a crawler ever reads — names the page in every language the app speaks and x-default (the unprefixed page, which chooses by Accept-Language), as absolute addresses. A page with no head { } of its own gets them too. A client-side navigation re-applies them with the rest of the head, so they always describe the page on screen.

The canonical follows the served language. A page's head { Canonical = … } is written once, as the source language's address ("/p/" + slug). Served as /sv/p/kiambu, the platform writes it — and og:url — as /sv/p/kiambu: a Swedish page whose canonical named the English one would be folded into it, and never shown to a Swedish searcher. A canonical on another host is the page's own statement and is left as written.

What the head says is in the served language. A head runs in the language the page is served in, so a [Translatable] member ([Translatable] — an entity's own words in every language) reads as that language: the title, og:title, the description and the structured data of /sv/p/kiambu are the product's Swedish words. An address a head builds for itself — a product's url in its structured data — should be the page in that language too; Session.Language says which (Osysharp.Shop's ProductUrl does this).

A served sitemap ([Serve("/sitemap.xml")], [Serve]) is written as for one language — a <url> per page, its <loc> absolute — and the platform lists each page once per language, every copy carrying the full set of <xhtml:link rel="alternate" hreflang> and whatever else the <url> held (lastmod, priority). A <url> that already names its alternates is left as written.

An app in one language gets none of this: its pages, heads and sitemap are exactly what its source says.

Examples#

entity Coffee {
  [Required, MaxLength(80)] string Slug;
  [Required, MaxLength(120), Translatable] string Name;
  [MaxLength(300), Translatable] string? Notes;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

[Route("/coffee/{slug}")]
[AllowAnonymous]
[Title("Coffee")]
component CoffeePage(string slug) {
  var coffee = Coffee.Where(c => c.Slug == slug).FirstOrDefault();
  head {
    Title = coffee == null ? "Not found" : coffee.Name;
    Description = coffee?.Notes;
    Canonical = "/coffee/" + slug;          // the source language's address — moved into the served one
  }
  render { Text(coffee?.Name ?? "Not found"); }
}

[Serve("/sitemap.xml")]
[AllowAnonymous]
string Sitemap() {
  var xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">";
  foreach (var c in Coffee.OrderBy(c => c.Slug).ToList()) {
    xml = xml + "<url><loc>" + App.BaseUrl + "/coffee/" + c.Slug + "</loc></url>";
  }
  return xml + "</urlset>";
}

translations sv {
  title "Coffee" => "Kaffe";
}

See also#

Related

Which language a reader gets — and switching it

A reader's language is, in order: their explicit choice (a `/sv/` address prefix or a language switch), their own…

head

A routed page's document head — its title, description, canonical address, share image, structured data, whether it may…

[Serve]

Gives a function an address on the app's own origin. A GET at that address answers with the function's return value as…

[Translatable] — an entity's own words in every language

`[Translatable]` on a stored `string` member keeps a translation per language, in a table beside its entity. Code…

A language's words — locales/<language>.osy

A project's translations are checked-in source: one `locales/<language>.osy` per language, a `translations sv { }`…