# `MishkaCmsCore.Runtime.Seo`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/seo.ex#L5)

What a database page puts in `<head>` for search engines and link previews, the tags an editor
can offer by name, and the variables a stored SEO value may use.

`MishkaCmsWeb.RuntimeLive` resolves a page's head in `handle_params/3`: it hands the plugins on
`event/0` the page's values, reads their answer with `accept/2`, and renders what `head/2`
returns.

## What the page sets, and what a plugin fills

A plugin on `event/0` answers with a page's defaults — what it reads off the record the page
serves. Whatever the page itself sets wins, and whatever it leaves empty is the plugin's:

  * the title is a stored `title` tag, the page's over the layout's, else the plugin's `title`,
    else the page's own;
  * the description is a stored `description` meta, the page's over the layout's, else the page's
    own, else a plugin's stored one, else the plugin's `description`;
  * the image is a stored `og:image`, else the page's share card, else the plugin's;
  * the JSON-LD is the page's stored `schema` when it holds an object, else the plugin's, else one
    `WebPage`. The plugin's and the `WebPage` are followed by the site's `WebSite` (`@id`
    `<base>/#website`) and the `Organization` that publishes it (`@id` `<base>/#organization`,
    with the `site.logo` setting as its `logo`), both named by the `site.title` setting, so an
    object that names either by its `@id` finds it on the page. `{{page.image}}` in it is the
    image the head resolved: a stored `og:image`, else the drawn share card, else the plugin's.

`og:title` and `twitter:title` say the title, `og:description` and `twitter:description` the
description, and `og:image` and `twitter:image` the image, unless a layer stores one of them.

## What a page shows

A page that serves one record per address — a post at `/:lang/blog/:slug` — says which kind in
its `extra`, `"record" => "blog:post"` (`record_of/1`), and a plugin on `event/0` answers for the
kinds it owns, whatever the page's path is. The kinds an editor can choose are what the installed
extensions answer on `records_event/0` (`record_types/1`).

The record's own `permissions` come back in the answer: `MishkaCmsWeb.RuntimeLive` opens the
address only for whoever holds one of them, after the page's own, and the head keeps it out of an
index. A plugin answers for each kind it owns even when it finds no record, so an answer the
plugins left untouched on a page that shows one — none of them answered: one raised, or the event
cannot run and hands back what it was given — means the record's lock could not be read, and the
page opens only for whoever holds `*`.

## Share cards

A public page whose head stores no `og:image` shows a share card, 1200×630, where a link to it is
shared (`MishkaCmsCore.Runtime.ShareCard`): its title, description, the site's name and logo
(`site_name/2`, the `site.logo` setting), and the plugin's `image` as its picture. A plugin adds what
its record has to show through `card` — `eyebrow`, `people`, `byline`, `meta`, `picture` — and
`card: false` keeps a page to the plugin's own image. Asked for only when `resolve/2` is given
`card: true`; until it is drawn the head shows the plugin's image.

The page's `<link rel="icon">` is the `site.favicon` setting, when it is set (`favicon/1`): a
`/…` path stays a path, so the browser asks the host it is on. A site whose `site.app_icon` or
`site.favicon` is a picture of its own also gets `<link rel="apple-touch-icon" sizes="180x180">`
and `<link rel="manifest" href="/site.webmanifest">` (`MishkaCmsCore.Runtime.SiteIcon`), both on
the host too. A site that set its `site.theme_color` gets `<meta name="theme-color">` with it
(`MishkaCmsCore.Runtime.SiteIcon.theme_color/1`), icons or not.

## Layers

`head/2` stacks the tags. A later layer replaces any earlier tag with the same identity — the
tag's name and every attribute except the one holding its value (`content` for a `meta` and a
`title`, `href` for a `link`):

  1. the defaults, built from the resolved title, description, image, address and language, each
     only when it has a value — the entries of `catalog/0` marked `default?`;
  2. the `tags` a plugin on `event/0` returned;
  3. the layout's `seo_tags`;
  4. the page's `seo_tags`.

What the page set itself is not undone by a plugin's tag: under a stored `title`, the plugin's
`og:title` and `twitter:title` are left out; under the page's own description, its `description`,
`og:description` and `twitter:description`; under a stored `og:image`, its `twitter:image` — so
each follows the page, unless the layout or the page stores that tag too.

`meta` and `link` tags render; a `title` tag is the title. An attribute name must be plain
(`^[a-z][a-z0-9_:.-]*$`), an `on…` handler is dropped, and so is a value that is not a string, a
number or a flag.

## Variables

A string in the page's description, in a stored tag (layers 2–4) or anywhere inside the schema
may name a variable as `{{key}}` or `{{ key }}`. `variables/0` lists the core ones; a plugin adds
its own through `vars`, which win on the same key. The `page.*` ones hold the defaults, before
the page's own title and description, so `{{page.title}}` in a stored `title` is the plugin's or
the page's title. A description or a tag holding a placeholder that does not resolve is left out,
a schema value that does not resolve is removed, and `{{` never reaches a visitor.

## Absolute addresses

Every address the defaults emit is on the site's own address, with the scheme and port the
endpoint answers on — the one the sitemap and robots.txt use (`base/1`,
`MishkaCmsCore.Helpers.site_url/2`): `http://localhost:4000/en/about` in development,
`https://example.com/en/about` in production. An `http://` or `https://` address stays as it is; a
protocol-relative one, a `javascript:` one and anything else is left out rather than emitted
(`absolute/2`). A stored `canonical`, `alternate`, `og:url`, `og:image` or `twitter:image` holding
a `/…` path is written on the same base.

No address ends in a slash (`MishkaCmsCore.Helpers.address_path/1`): the home page is the site's
address alone, `https://example.com`, as the sitemap lists it, in the canonical address, `og:url`,
the `hreflang` alternates and the JSON-LD alike.

## Pages kept out of an index

The default `robots` is `noindex, nofollow` for a page that names a permission or that is a
sign-in page (`noindex?/2`), and for a record that names one; a stored `robots` tag replaces it as
any other default.

## For an editor

`catalog/0`, `variables/0` and `schema_types/0` describe what an editor offers. `stored_tags/1`,
`stored_tag/3`, `stored_value/1`, `entry_of/1`, `tag_value/2`, `put_tag/2` and `drop_tag/2` read
and write stored tags; `schema_object/3` and `schema_values/1` write and read one JSON-LD object;
`resolve/2` is the head a page would render, which a preview draws.

# `head`

```elixir
@type head() :: %{
  title: String.t() | nil,
  description: String.t() | nil,
  image: String.t() | nil,
  url: String.t() | nil,
  card: map() | nil,
  tags: [tag()],
  schema: [String.t()],
  var_docs: [map()],
  permissions: [String.t()]
}
```

# `tag`

```elixir
@type tag() :: {String.t(), [{String.t(), String.t() | number() | boolean()}]}
```

# `absolute`

```elixir
@spec absolute(term(), String.t() | nil) :: String.t() | nil
```

An `http://…` or `https://…` address as it is, a `/…` path on `base`, `nil` for anything else —
a protocol-relative address, a `javascript:` one, a relative path.

An address on the site itself, a path or one that starts with `base`, is written as
`MishkaCmsCore.Helpers.address_path/1` writes a path: no slash at its end, so the home page is
`base` alone.

    absolute("/en/about/", "https://example.com") #=> "https://example.com/en/about"
    absolute("/", "https://example.com") #=> "https://example.com"
    absolute("https://example.com/", "https://example.com") #=> "https://example.com"
    absolute("//cdn.example.com/a.png", "https://example.com") #=> nil

# `accept`

```elixir
@spec accept(term(), map()) :: map()
```

The map a plugin on `event/0` returned, read against the one it was given. Each key keeps the
plugin's value when it has the right type and the given one otherwise; `page`, `site_id`,
`params`, `record`, `redraw` and `source` are always the given ones. Anything but a map is the
given map.

The page's own `description` and `schema` win: the plugin's fill them only when the page's are
empty.

  * `title`, `type`, `language` — a non-empty string
  * `description`, `image`, `url` — a string or `nil`
  * `tags`, `schema`, `var_docs` — a list
  * `vars` — a map
  * `permissions` — a list; what in it is not a string is dropped

# `alternate_links`

```elixir
@spec alternate_links([map()], String.t() | nil, String.t() | nil) :: [map()]
```

The `rel="alternate"` links of a page in several languages: one per edition, at its absolute
address (`absolute/2`), and one `hreflang="x-default"` at the edition in the site's primary
language (`MishkaCmsCore.Runtime.Languages.primary/1`), when there is one. `[]` for fewer than two
editions, which have no alternate to offer.

`editions` is `[%{language: language, path: path}]`, each `path` a public address —
`MishkaCmsCore.Runtime.Languages.url/3`'s. The head's own are `:languages` of `head/2`; a plugin
answering for a record puts the record's in its `tags` from here.

    Seo.alternate_links([%{language: "en", path: "/about"}, %{language: "fa", path: "/fa/about"}],
      "https://acme.test", site.id)
    #=> [%{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "en", "href" => "https://acme.test/about"}},
    #    %{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "fa", "href" => "https://acme.test/fa/about"}},
    #    %{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "x-default", "href" => "https://acme.test/about"}}]

# `base`

```elixir
@spec base(String.t() | nil) :: String.t() | nil
```

A site's own address with no path, on the scheme and port the endpoint answers on — the base the
sitemap and robots.txt use (`MishkaCmsCore.Helpers.site_url/2`); `nil` for no host.

    base("blog.example.com") #=> "https://blog.example.com" in production
    base("localhost")        #=> "http://localhost:4000" in development

# `catalog`

```elixir
@spec catalog() :: [map()]
```

Every tag the head knows by name, in the order the head emits the defaults.

Each entry:

  * `:id` — the identity's value: `"og:title"`, `"canonical"`
  * `:tag` and `:attrs` — the identity, as a stored tag writes it: `"meta"`,
    `%{"property" => "og:title"}`
  * `:value` — the attribute that holds the value: `"content"` for a meta, `"href"` for a link
  * `:needs` — other attributes the author fills: `["hreflang"]` for a language version
  * `:label`, `:help` — for an editor, in the current locale
  * `:default?` — whether the head emits it when nothing stored names it
  * `:values` — the allowed values of an enumerated tag, else `[]`
  * `:multiple?` — whether several `:values` combine, comma separated (`robots`)
  * `:default` — the value an editor fills in when the tag is added, which may name a variable
  * `:repeatable?` — whether a page holds several, one value each (`article:tag`)

# `drop_tag`

```elixir
@spec drop_tag([map()], String.t()) :: [map()]
```

`tags` without any the catalog entry `id` names.

# `entry_of`

```elixir
@spec entry_of(map()) :: map() | nil
```

The catalog entry a stored tag is, `nil` for one the catalog does not name.

# `event`

```elixir
@spec event() :: String.t()
```

The event a page's SEO is resolved through, called once per `handle_params/3` with the map
`MishkaCmsWeb.RuntimeLive` documents.

# `favicon`

```elixir
@spec favicon(String.t() | nil) :: String.t() | nil
```

The site's favicon as a page or a redirect names it: its `site.favicon` setting, read as
`site_picture/2` reads one.

    favicon(site_id) #=> "/uploads/<site>/media/icon.png"

# `given`

```elixir
@spec given(map(), keyword()) :: map()
```

The map `event/0` is called with for `page`, before any plugin has answered.

`page` is the `:page` section of a compiled page's `__page_information__/0`, or any map holding
its `title`, `language` and `seo` (`description`, `schema`). Options: `:site_id`, `:url` (the
canonical address), `:params` (`@query_params`, `%{}` by default) and `:record`, the kind of
record the page shows (`record_of/1`). An editor reads a dynamic page's `var_docs` by calling
`event/0` with this map and no `:params`.

`:redraw` is `true` when a forced look at the page's share card asks for the map
(`MishkaCmsCore.Runtime.ShareCard.specs/3`): a plugin that draws a card of its own draws it again
over its old file. `false` by default.

`:source` is what the page's plugin on `MishkaCmsCore.Runtime.PageSource.event/0` read for this
request, as the visitor (`@page_source`): a plugin that finds the record there takes it from there
rather than reading it again. `nil` by default, as for an editor's preview and a share card, where
a plugin reads for itself.

# `head`

```elixir
@spec head(map(), keyword()) :: head()
```

The head of a page, from the map `accept/2` returned.

`seo` holds `title`, `description`, `image`, `type`, `url`, `language`, `tags`, `schema`, `vars`,
`permissions` and `site_id`. Options:

  * `:base` — `base/1` of the site's host
  * `:languages` — the page's editions, `[%{language:, path:}]`, `[]` when it has one
  * `:restricted?` — whether the page is kept out of an index, `noindex?/2`; a record that names
    `permissions` is kept out too
  * `:layers` — the layout's and the page's stored `seo_tags`, in that order
  * `:page_description?` — whether `description` is the page's own rather than a plugin's
  * `:page_schema?` — whether `schema` is the page's own rather than a plugin's; `false` by
    default, which adds the site's `WebSite` and `Organization` after it
  * `:page_number` — the page of a list this address shows (`page_number/1`); past the first,
    the title says so, `Blog – Page 2`, so no two pages of a list share one
  * `:card` — whether a page with no stored `og:image` shows its share card (`ShareCard.image/2`),
    `false` by default: a preview asks for none; `:spec` returns what the card would show in
    `card` without asking for it

A stored `title` replaces `title`, a stored `description` meta `description`, and a stored
`og:image` `image`, else the share card does once it is drawn; with `:page_description?`, a
plugin's `description` meta does not replace the page's own. The `description` has its variables
filled; empty, or naming one that does not resolve, a stored `description` meta stands in.

Returns the resolved `title`, `description`, `image` and `url`, the share `card`'s spec (with
`:card`), the `tags` to render as
`{name, attributes}`, the `schema` as JSON strings safe inside `<script>`, and the `var_docs` and
`permissions` a plugin answered.

# `locale`

```elixir
@spec locale(String.t() | nil) :: String.t() | nil
```

A language code as Open Graph writes a locale, `ll_RR`: its language and the two-letter region
the code names, else the one CLDR's likely subtags give the language; the language alone when
CLDR gives none, or only a numeric region. `nil` for what is no language code.

    locale("pt-br")  #=> "pt_BR"
    locale("en")     #=> "en_US"
    locale("fa")     #=> "fa_IR"
    locale("es-419") #=> "es"

# `noindex?`

```elixir
@spec noindex?(term(), term()) :: boolean()
```

Whether a page is kept out of an index unless a stored `robots` tag says otherwise: it names a
permission, or its `extra` marks it as a sign-in page — `"visitors_only"` or an `"auth_role"`, as
`MishkaCmsCore.Install.StarterContent` writes them. Pass `head/2` the answer as `:restricted?`.

    noindex?([], %{"auth_role" => "sign_in"}) #=> true
    noindex?(["members:read"], nil)           #=> true
    noindex?([], %{})                         #=> false

# `page_number`

```elixir
@spec page_number(term()) :: pos_integer() | nil
```

The number of the page of a list an address asks for, `?page=2`; `nil` for the first and for an
address that asks for none.

# `put_tag`

```elixir
@spec put_tag([map()], map()) :: [map()]
```

`tags` with `tag` written in place of the first one with its identity — its name and attributes
but its value — or after them when there is none. A repeatable catalog entry (`article:tag`) is
always written after them.

# `record_of`

```elixir
@spec record_of(term()) :: String.t() | nil
```

The kind of record a page shows, from its `extra`: `"blog:post"`, `nil` for none.

    record_of(%{"record" => "blog:post"}) #=> "blog:post"

# `record_types`

```elixir
@spec record_types(String.t() | nil) :: [map()]
```

The kinds of record the installed extensions offer for `site_id`, each as `records_event/0`
describes, sorted by group; an entry without an `id`, a `group` and a `label` is left out.

# `records_event`

```elixir
@spec records_event() :: String.t()
```

The event an extension names the kinds of record its pages show on. It is called with
`%{site_id: id, records: []}`, and each plugin returns the map with its own appended to `records`:

  * `:id` — the kind, `"blog:post"`, which a page stores and its plugin on `event/0` answers for
  * `:group`, `:label` — what an editor lists it under and as, `"Blog"` and `"Post"`
  * `:captures` — the captures its address must have, `["slug"]`, `[]` for none
  * `:samples` — a few records to preview a page with,
    `[%{label: "A post", params: %{"slug" => "a-post"}}]`

# `resolve`

```elixir
@spec resolve(map(), keyword()) :: head()
```

The head `page` renders at one address: `given/2`, then the plugins on `event/0`, `accept/2` and
`head/2`. `MishkaCmsWeb.RuntimeLive` calls it for every address it serves; an editor's preview
calls it with no `:params`, and reads the plugin's variables from `var_docs`.

Options: `:site_id`, `:url`, `:params`, `:redraw` and `:source` as `given/2` takes them; `:base`,
`:languages`, `:restricted?` and `:layers` as `head/2` takes them; and `:stand_ins` — `true` for an
editor's preview, where a page that serves records has none to read: each variable a plugin
describes in `var_docs` but did not fill reads as `{key}`, `{post.title}`, so the preview shows
where it goes.

# `schema_object`

```elixir
@spec schema_object(String.t(), map(), map()) :: map() | nil
```

The JSON-LD object of `type`, one of `schema_types/0`, with an editor's `values` written over
`object`: each field keyed as `schema_types/0` names it, a blank value removing it, and every other
key of `object` kept. A `FAQPage` reads `"questions"`, `[%{"question" => …, "answer" => …}]`, and
leaves out a pair with no question. `nil` for a type `schema_types/0` does not name.

    schema_object("Article", %{"headline" => "{{page.title}}", "author.name" => "Ada"})
    #=> %{"@context" => "https://schema.org", "@type" => "Article",
    #     "headline" => "{{page.title}}", "author" => %{"@type" => "Person", "name" => "Ada"}}

# `schema_types`

```elixir
@spec schema_types() :: [map()]
```

The JSON-LD types an editor offers beside the automatic `WebPage`, each with the few fields
search engines read, in the current locale.

Each entry:

  * `:type` — the `@type`, `"Article"`
  * `:label` — for an editor
  * `:fields` — `[%{key:, label:, default:}]`; `default` is the value an editor fills in, which
    may name a variable. A key with a dot, `"author.name"`, is a value of a nested object
  * `:nested` — the `@type` of each nested object, by its key: `%{"author" => "Person"}`
  * `:faq?` — whether the object holds questions and answers instead of fields (`FAQPage`)
  * `:stars?` — whether search engines may show its rating as stars: a review snippet is offered
    for a product, a recipe, a course, a book, an application, an event and a local business,
    never for an article or a blog post

# `schema_values`

```elixir
@spec schema_values(term()) :: {String.t(), map()} | nil
```

An editor's values for a stored JSON-LD object: `{type, values}` when its `@type` is one of
`schema_types/0`, keyed as `schema_object/2` reads them; `nil` for any other object.

# `site_name`

```elixir
@spec site_name(String.t() | nil, String.t() | nil) :: String.t() | nil
```

The site's name in `language`: the `site.title` setting, translated when the site's Translations
hold that title in `language` (`MishkaCmsCore.Runtime.States.TranslationsState`), `nil` when the
setting is empty.

    site_name(site_id, "fa") #=> "میشکا"

# `site_picture`

```elixir
@spec site_picture(String.t(), String.t() | nil) :: String.t() | nil
```

The picture the setting `key` names for the site `site_id` — its own value else the global one —
read off the address `URI.new/1` parses, never off its text: a path on the site (no scheme, host
or user info, starting with one `/`) as it is, or an `http`/`https` address with a host and no
user info. `nil` for no picture and for anything else — `//host/…`, a `\` a browser would read
as `/`, `javascript:`, `data:`.

    site_picture("site.app_icon", site_id) #=> "/uploads/<site>/media/app-icon.png"

# `stored_tag`

```elixir
@spec stored_tag(String.t(), map(), String.t()) :: map()
```

A stored tag for `tag` with `attrs` holding `value`: a meta's in `content`, a link's in its `href`.

    stored_tag("meta", %{"property" => "og:image"}, "/a.png")
    #=> %{tag: "meta", attrs: %{"property" => "og:image"}, content: "/a.png"}

# `stored_tags`

```elixir
@spec stored_tags(term()) :: [%{tag: term(), attrs: map(), content: term()}]
```

Stored tags in one shape, `%{tag:, attrs:, content:}` with string attribute names, whichever keys
they were stored with. Anything that is not a map is left out; a tag the head will not render is
kept, so writing the list back loses nothing.

# `stored_value`

```elixir
@spec stored_value(map()) :: String.t() | nil
```

The value a stored tag holds: a meta's `content`, a link's `href`.

# `tag_value`

```elixir
@spec tag_value([map()], String.t()) :: String.t() | nil
```

The value of the first of `tags` (`stored_tags/1`) the catalog entry `id` names, `nil` for none.

# `variables`

```elixir
@spec variables() :: [%{key: String.t(), label: String.t(), help: String.t()}]
```

The variables every page fills, each `%{key:, label:, help:}` for an editor, in the current
locale. A stored value names one as `{{page.title}}`.

A dynamic page's own variables come from its plugin on `event/0`: it returns their values in
`vars` and describes them in `var_docs`, `[%{key: "post.title", label: "Post title"}]`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
