Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

head

[Route("/p/{slug}")] component P(string slug) { head { Title = …; Description = …; Canonical = …; } render { … } }

A routed page's document head — its title, description, canonical address, share image, structured data, whether it may be indexed, and whether it has moved. Each value may read the page's own data, and the head is written into the page's first response whatever the page's render mode, so a crawler reads it without running anything.

stable1 example compiled by CIuiseoroutingpage

Summary#

head { } is what a page says about itself to everything that is not a person looking at it: the browser tab, a bookmark, a search result, a link preview in a chat, a crawler reading structured data. A product page says which product it is; a moved page says where it went.

[Route("/p/{slug}")]
[AllowAnonymous]
component ProductPage(string slug) {
  var product = Product.Where(p => p.Slug == slug).FirstOrDefault();
  head {
    Title = product == null ? "Not found" : product.Name;
    Description = product?.Summary;
    Canonical = "/p/" + slug;
  }
  render { … }
}

Signature#

head {
  Title          = <string?>;   // the <title> — the tab, a bookmark, a search result's headline (also og:title)
  Description    = <string?>;   // <meta name="description"> — a search result's snippet (also og:description)
  Canonical      = <string?>;   // <link rel="canonical"> — the one address this content lives at (also og:url)
  Image          = <string?>;   // og:image — the picture a shared link shows
  StructuredData = <JsonLd?>;   // JSON-LD — an Osysharp.Seo block, JsonLd.Raw(json), or [a, b] for several
  NoIndex        = <bool>;      // <meta name="robots" content="noindex">
  RedirectTo     = <string?>;   // answer 301 to this path instead of rendering the page
}

Every setting is optional, and each may be set once. A null value writes no tag. The block belongs to a routed page (one with [Route("/…")]); on any other component it is a compile error, because nothing else has a document.

Description#

It reads the page's own data#

A setting is an ordinary expression, and it may read the page's parameters and its members — a var product = Product.Where(…).FirstOrDefault(); declared on the page is readable from the head exactly as it is from render. Only the members the head actually reads are computed for it, so a member that only makes sense in the browser does not stop a head that never looks at it.

A wrong type is the ordinary assignment error, at the line that wrote it: NoIndex = "yes"; is refused because NoIndex is a bool.

It is in the first response — whatever [Render] says#

A crawler reads the head of the first response and runs nothing. So the head is computed on the server for every page a signed-out visitor may open ([AllowAnonymous]), including a [Render(CSR)] page that paints nothing on the server at all. Relative Canonical and Image values are made absolute against the address the request came to, because the machines that read them do not resolve a relative address the way a browser does.

A page that requires a sign-in is reached by a hard navigation that carries no credential, so its head is not computed there: the tab shows its [Title] until the client has signed in, and then the head is applied as below. Nothing the head would have computed reaches a visitor the page itself refuses.

And it follows the visitor around the app#

Every page after the first is reached by the client router, which never asks for a new document. After each such navigation the client asks the server for the new page's head — computed as the signed-in caller, and refused to a caller the page itself refuses ([AllowAnonymous] / [Authorize] apply exactly as they do to the page) — and replaces the previous page's head tags with it. A page with no head clears them, so a canonical address or structured data never outlives the page it described. A page with no head { } costs no request.

Title: the head's wins the tab#

The browser tab reads, most specific first: the head's Title, then the page's [Title("…")], then the component's own name. [Title] goes on naming the route for the app's own chrome — a breadcrumb, a tab strip reading Navigation.Routes — so a page can be "Blue Kettle — Shop" in the browser and "Product" in its breadcrumb.

A moved page answers 301#

RedirectTo makes the page answer 301 Moved Permanently before anything renders, which is what tells a search engine to carry the old address's standing over to the new one. It accepts a path on this app — one that starts with a single /. A value naming another host (https://…, or //host, which a browser also reads as another host) is not followed: the page is served instead and the server logs why. That is deliberate — a head computes its value from data and from the address, and a head that forwarded one would be a way for any link to send your visitors to another site from your own domain.

After a client navigation the same rule applies, and the router replaces the address rather than adding one.

Structured data: typed blocks, or your own JSON#

StructuredData holds a JsonLd block — what search engines read to show a product's price and stock, a breadcrumb trail or a site's name in a result. The Osysharp.Seo kit ships schema.org's shapes as classes that derive from it (SeoProduct, SeoOffer, SeoBreadcrumbList, SeoOrganization, SeoWebSite), each writing its own @context and @type. JsonLd.Raw(json) is JSON you wrote yourself, for anything the kit does not model. Several blocks are a list — StructuredData = [product, breadcrumbs]; — which is written as one JSON-LD array (JsonLd.Of(…) spells the same thing).

It cannot break out of its script. The block's JSON is written into a <script type="application/ld+json"> after it is parsed — text that is not JSON is dropped, never written — and every < in it is written as the escape \u003c, which is the same character to a JSON reader and can never end the script element. So a product whose name is </script><script>… shows up in the structured data as that name, and not as script on your page.

What a head is not#

  • Not per-visitor chrome. It is computed for the page's address. A head that greets the signed-in user by name would show that name to a crawler reading the anonymous response — use the page's render for that.
  • Not on a generic page. A head is computed from the address alone, and an address cannot choose a type argument, so head { } on component Board<T>(…) is refused.

Examples#

A product page that names its product#

entity Product {
  [MaxLength(200)] string Name;
  [MaxLength(200)] string Slug;
  [MaxLength(500)] string? Summary;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

[Route("/p/{slug}")]
[AllowAnonymous]
[Title("Product")]
component ProductPage(string slug) {
  var product = Product.Where(p => p.Slug == slug).FirstOrDefault();
  head {
    Title = product == null ? "Not found" : product.Name + " | Shop";
    Description = product == null ? null : product.Summary;
    Canonical = "/p/" + slug;
    Image = "/img/" + slug + ".png";
    StructuredData = product == null ? null
      : JsonLd.Raw("{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"name\":" + JsonSerializer.Serialize(product.Name) + "}");
    NoIndex = product == null;
    RedirectTo = slug == "kettle-blue" ? "/p/blue-kettle" : null;
  }
  render { Text(product == null ? "Not found" : product.Name); }
}

What the compiler refuses#

[Route("/p/{slug}")]
component ProductPage(string slug) {
  head {
    Title = "Kettle";
    Keywords = "kettle, blue";   // ERROR: a head sets `Title` (string?), `Description` (string?), `Canonical` (string?),
  }                              //        `Image` (string?), `StructuredData` (JsonLd?), `NoIndex` (bool), `RedirectTo` (string?)
  render { … }
}

component Card() {
  head { Title = "Card"; }       // ERROR: only a routed page has a document head — this component has no [Route("/…")]
  render { … }
}

meta { title = "…"; }, the block this replaced, is refused by name with the rewrite: head { Title = "…"; }.

See also#

Related

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

Navigation

The routes the user has open, which of them hold UNSAVED edits, and the verbs that move between them or close one…

page authorization (policies)

A `policy` names a reusable authorization predicate over the current user — e.g. `policy Admins => UserRole.Any(r =>…

The SEO kit — schema.org structured data for a page's head

`Osysharp.Seo` ships schema.org's shapes as classes: a product with its offer, rating and brand, an article, a…

[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…